/**
* (Legacy)可乐不加冰 v1.0.0 - SillyTavern 插件
* 模拟微信界面,支持导入角色卡
*/
import { saveSettingsDebounced, getRequestHeaders } from '../../../../script.js';
import { getContext, extension_settings, renderExtensionTemplateAsync } from '../../../extensions.js';
import { world_names, loadWorldInfo, saveWorldInfo, createNewWorldInfo } from '../../../world-info.js';
// 插件名称
const extensionName = 'wechat-simulator';
// 默认设置
const defaultSettings = {
darkMode: true, // 默认开启深色模式
autoInjectPrompt: true,
contacts: [], // 存储导入的角色卡
phoneVisible: false,
userAvatar: '', // 用户自定义头像
// API 配置
apiUrl: '',
apiKey: '',
selectedModel: '', // 选中的模型
modelList: [], // 缓存的模型列表
// 总结功能 API 配置
summaryApiUrl: '',
summaryApiKey: '',
summarySelectedModel: '',
summaryModelList: [],
// 上下文设置
contextEnabled: false, // 上下文开关(需要主界面有聊天时才启用)
contextLevel: 5, // 0-5层,参考酒馆主聊天
contextTags: [], // 自定义提取标签,如 ['content', 'scene', 'action']
walletAmount: '5773.89', // 钱包金额
};
// 作者注释模板
const authorNoteTemplate = `[微信消息格式指南]
当角色想要通过手机微信发送消息时,请使用以下格式:
- 普通消息:[微信: 消息内容]
- 语音消息:[语音: 秒数] 例如 [语音: 5秒]
- 图片消息:[图片: 图片描述]
- 朋友圈:[朋友圈: 内容 | 图片描述]
- 表情:[表情: 表情描述]
- 撤回消息:[撤回]
- 红包:[红包: 祝福语]
- 转账:[转账: 金额]
示例:
[微信: 你在干嘛呢?]
[语音: 10秒]
[微信: 刚录了条语音给你听~]`;
// 初始化设置
function loadSettings() {
extension_settings[extensionName] = extension_settings[extensionName] || {};
if (Object.keys(extension_settings[extensionName]).length === 0) {
Object.assign(extension_settings[extensionName], defaultSettings);
}
}
// 获取当前时间字符串
function getCurrentTime() {
const now = new Date();
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
}
// 获取用户头像HTML
function getUserAvatarHTML() {
const settings = extension_settings[extensionName];
const context = getContext();
const userName = context?.name1 || 'User';
const firstChar = userName.charAt(0);
// 优先使用自定义头像
if (settings.userAvatar) {
return `
`;
}
// 其次尝试从 SillyTavern 获取
const userAvatar = context?.user_avatar;
if (userAvatar) {
// 尝试多种路径格式
const avatarPaths = [
`/User Avatars/${userAvatar}`,
`/characters/${userAvatar}`,
userAvatar
];
// 使用第一个路径,onerror 时会显示首字母
return `
`;
}
// 尝试从 getUserPersonaFromST 获取
const stPersona = getUserPersonaFromST();
if (stPersona?.avatar) {
return `
`;
}
// 默认显示首字母
return firstChar;
}
// 生成手机界面 HTML
function generatePhoneHTML() {
const settings = extension_settings[extensionName];
const darkClass = settings.darkMode ? 'wechat-dark' : '';
const hiddenClass = settings.phoneVisible ? '' : 'hidden';
return `
${generateContactsList()}
我
${getUserAvatarHTML()}
User
微信号:${settings.wechatId || 'SillyTavern'}
+ 状态
服务
上下文
${settings.contextEnabled ? '已开启' : '已关闭'}
钱包
¥${settings.walletAmount || '5773.89'}
层数
${settings.contextLevel ?? 5}
提取标签
${(settings.contextTags || []).map((tag, i) => `
<${tag}>
`).join('')}
从主界面聊天消息中提取指定标签内容
API URL
API Key
模型
收集所有聊天记录,生成世界书并同步到酒馆
发送语音消息
输入语音内容(将显示为语音条)
预计时长:
0"
`;
}
// 生成聊天列表 HTML(微信主页列表样式)
function generateChatList() {
const settings = extension_settings[extensionName];
const contacts = settings.contacts || [];
if (contacts.length === 0) {
return `
`;
}
// 获取有聊天记录的联系人,按最后消息时间排序
const contactsWithChat = contacts.map((contact, index) => {
const chatHistory = contact.chatHistory || [];
const lastMsg = chatHistory.length > 0 ? chatHistory[chatHistory.length - 1] : null;
// 使用 timestamp 或从 time 字符串解析时间
const lastMsgTime = lastMsg ? (lastMsg.timestamp || new Date(lastMsg.time).getTime() || 0) : 0;
// 确保有 ID,没有则使用索引
const contactId = contact.id || `idx_${index}`;
return { ...contact, id: contactId, originalIndex: index, lastMsg, lastMsgTime };
}).filter(c => c.lastMsg).sort((a, b) => b.lastMsgTime - a.lastMsgTime);
if (contactsWithChat.length === 0) {
return `
`;
}
return contactsWithChat.map(contact => {
const lastMsg = contact.lastMsg;
let preview = '';
if (lastMsg.type === 'voice' || lastMsg.isVoice) {
preview = '[语音消息]';
} else if (lastMsg.type === 'image') {
preview = '[图片]';
} else {
preview = lastMsg.content || '';
if (preview.length > 20) preview = preview.substring(0, 20) + '...';
}
// 格式化时间
const msgTime = contact.lastMsgTime ? formatChatTime(contact.lastMsgTime) : '';
const avatarContent = contact.avatar
? `
`
: `${contact.name?.charAt(0) || '?'}`;
return `
${avatarContent}
${contact.name || '未知'}
${preview}
${msgTime}
`;
}).join('');
}
// 格式化聊天时间
function formatChatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const oneDay = 24 * 60 * 60 * 1000;
if (diff < oneDay && date.getDate() === now.getDate()) {
// 今天,显示时:分
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
} else if (diff < 2 * oneDay && date.getDate() === now.getDate() - 1) {
return '昨天';
} else if (diff < 7 * oneDay) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return days[date.getDay()];
} else {
return `${date.getMonth() + 1}/${date.getDate()}`;
}
}
// 刷新聊天列表
function refreshChatList() {
const chatListEl = document.getElementById('wechat-chat-list');
if (chatListEl) {
chatListEl.innerHTML = generateChatList();
}
}
// 通过联系人ID打开聊天
function openChatByContactId(contactId, index) {
const settings = extension_settings[extensionName];
const contacts = settings.contacts || [];
// 先尝试通过 ID 查找
let contactIndex = contacts.findIndex(c => c.id === contactId);
// 如果找不到,尝试使用索引(兼容 idx_N 格式)
if (contactIndex === -1 && contactId.startsWith('idx_')) {
contactIndex = parseInt(contactId.replace('idx_', ''));
}
// 如果还是找不到,使用传入的 index
if (contactIndex === -1 && typeof index === 'number') {
contactIndex = index;
}
if (contactIndex >= 0 && contactIndex < contacts.length) {
openChat(contactIndex);
}
}
// 生成联系人列表 HTML(图片网格样式)
function generateContactsList() {
const settings = extension_settings[extensionName];
const contacts = settings.contacts || [];
if (contacts.length === 0) {
return `
`;
}
return ``;
}
// 从 PNG 提取角色卡数据 (V2 格式)
async function extractCharacterFromPNG(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async function(e) {
try {
const arrayBuffer = e.target.result;
const dataView = new DataView(arrayBuffer);
// 检查 PNG 签名
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < 8; i++) {
if (dataView.getUint8(i) !== pngSignature[i]) {
throw new Error('不是有效的 PNG 文件');
}
}
// 遍历 PNG chunks 寻找 tEXt 或 iTXt chunk
let offset = 8;
while (offset < arrayBuffer.byteLength) {
const length = dataView.getUint32(offset);
const type = String.fromCharCode(
dataView.getUint8(offset + 4),
dataView.getUint8(offset + 5),
dataView.getUint8(offset + 6),
dataView.getUint8(offset + 7)
);
if (type === 'tEXt' || type === 'iTXt') {
const chunkData = new Uint8Array(arrayBuffer, offset + 8, length);
const text = new TextDecoder('utf-8').decode(chunkData);
// 检查是否是角色卡数据
if (text.startsWith('chara\0')) {
const base64Data = text.substring(6);
// 正确处理 UTF-8 编码的 Base64 解码
const binaryStr = atob(base64Data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
const jsonStr = new TextDecoder('utf-8').decode(bytes);
const charData = JSON.parse(jsonStr);
// 获取图片作为头像 (转为base64以便持久化存储)
const uint8Array = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < uint8Array.length; i++) {
binary += String.fromCharCode(uint8Array[i]);
}
const avatarBase64 = 'data:image/png;base64,' + btoa(binary);
resolve({
name: charData.name || charData.data?.name || '未知角色',
description: charData.description || charData.data?.description || '',
avatar: avatarBase64,
rawData: charData
});
return;
}
}
offset += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC)
}
throw new Error('PNG 文件中未找到角色卡数据');
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
}
// 从 JSON 导入角色卡
async function extractCharacterFromJSON(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
try {
const charData = JSON.parse(e.target.result);
resolve({
name: charData.name || charData.data?.name || '未知角色',
description: charData.description || charData.data?.description || charData.personality || '',
avatar: charData.avatar || null,
rawData: charData
});
} catch (err) {
reject(new Error('JSON 解析失败'));
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
});
}
// 导入角色卡到 SillyTavern
async function importCharacterToST(characterData) {
try {
const context = getContext();
// 创建一个格式化的角色卡对象
const formData = new FormData();
// 如果有原始文件数据,使用它
if (characterData.file) {
formData.append('avatar', characterData.file);
}
// 调用 SillyTavern 的角色导入 API
const response = await fetch('/api/characters/import', {
method: 'POST',
headers: getRequestHeaders(),
body: formData
});
if (!response.ok) {
throw new Error('导入失败');
}
return await response.json();
} catch (err) {
console.error('导入角色卡失败:', err);
throw err;
}
}
// 添加联系人
function addContact(characterData) {
const settings = extension_settings[extensionName];
const now = new Date();
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
// 检查是否已存在
const exists = settings.contacts.some(c => c.name === characterData.name);
if (exists) {
showToast('该角色已在联系人列表中', '⚠️');
return false;
}
settings.contacts.push({
id: 'contact_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
name: characterData.name,
description: characterData.description?.substring(0, 50) + '...' || '',
avatar: characterData.avatar,
importTime: timeStr,
rawData: characterData.rawData
});
saveSettingsDebounced();
refreshContactsList();
return true;
}
// 刷新联系人列表
function refreshContactsList() {
const contactsContainer = document.getElementById('wechat-contacts');
if (contactsContainer) {
contactsContainer.innerHTML = generateContactsList();
bindContactsEvents();
}
}
// 绑定联系人点击事件
function bindContactsEvents() {
// 单击卡片进入聊天(点击头像除外)
document.querySelectorAll('.wechat-card-content').forEach(card => {
card.addEventListener('click', function(e) {
// 如果点击的是头像,不进入聊天(用于换头像)
if (e.target.closest('.wechat-card-avatar')) return;
const cardEl = this.closest('.wechat-contact-card');
const index = parseInt(cardEl.dataset.index);
openChat(index);
});
});
// 单击头像更换角色头像
document.querySelectorAll('.wechat-card-avatar').forEach(avatar => {
avatar.addEventListener('click', function(e) {
e.stopPropagation();
const index = parseInt(this.dataset.index);
changeContactAvatar(index);
});
});
// 删除按钮点击
document.querySelectorAll('.wechat-card-delete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const index = parseInt(this.dataset.index);
deleteContact(index);
});
});
// 初始化滑动删除功能(支持触摸和鼠标)
initSwipeToDelete();
}
// 删除联系人
function deleteContact(index) {
const settings = extension_settings[extensionName];
const contact = settings.contacts[index];
if (!contact) return;
if (confirm(`确定要删除 ${contact.name} 吗?`)) {
settings.contacts.splice(index, 1);
saveSettingsDebounced();
refreshContactsList();
}
}
// 初始化滑动删除功能
function initSwipeToDelete() {
const cards = document.querySelectorAll('.wechat-contact-card');
cards.forEach(card => {
const wrapper = card.querySelector('.wechat-card-swipe-wrapper');
if (!wrapper || wrapper.dataset.swipeInit) return;
wrapper.dataset.swipeInit = 'true';
let startX = 0;
let currentX = 0;
let isDragging = false;
let isOpen = false;
const deleteWidth = 70; // 删除按钮宽度
// 触摸开始 / 鼠标按下
const handleStart = (e) => {
// 如果点击的是头像,不触发滑动
if (e.target.closest('.wechat-card-avatar')) return;
isDragging = true;
startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
wrapper.style.transition = 'none';
};
// 触摸移动 / 鼠标移动
const handleMove = (e) => {
if (!isDragging) return;
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const diff = clientX - startX;
// 计算新位置
let newX;
if (isOpen) {
newX = -deleteWidth + diff;
} else {
newX = diff;
}
// 限制滑动范围
newX = Math.max(-deleteWidth, Math.min(0, newX));
currentX = newX;
wrapper.style.transform = `translateX(${newX}px)`;
};
// 触摸结束 / 鼠标松开
const handleEnd = () => {
if (!isDragging) return;
isDragging = false;
wrapper.style.transition = 'transform 0.3s ease';
// 判断是否打开或关闭
if (currentX < -deleteWidth / 2) {
// 打开删除按钮
wrapper.style.transform = `translateX(-${deleteWidth}px)`;
isOpen = true;
} else {
// 关闭删除按钮
wrapper.style.transform = 'translateX(0)';
isOpen = false;
}
};
// 关闭其他卡片的删除按钮
const closeOthers = () => {
cards.forEach(otherCard => {
if (otherCard !== card) {
const otherWrapper = otherCard.querySelector('.wechat-card-swipe-wrapper');
if (otherWrapper) {
otherWrapper.style.transition = 'transform 0.3s ease';
otherWrapper.style.transform = 'translateX(0)';
}
}
});
};
// 触摸事件
wrapper.addEventListener('touchstart', (e) => {
closeOthers();
handleStart(e);
}, { passive: true });
wrapper.addEventListener('touchmove', handleMove, { passive: true });
wrapper.addEventListener('touchend', handleEnd);
// 鼠标事件(电脑端支持)
const onMouseMove = (e) => handleMove(e);
const onMouseUp = () => {
handleEnd();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
wrapper.addEventListener('mousedown', (e) => {
// 如果点击的是头像,不触发滑动
if (e.target.closest('.wechat-card-avatar')) return;
closeOthers();
handleStart(e);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
});
}
// 更换角色头像
let pendingAvatarContactIndex = -1;
function changeContactAvatar(contactIndex) {
pendingAvatarContactIndex = contactIndex;
// 使用动态创建的 input
let input = document.getElementById('wechat-contact-avatar-input');
if (!input) {
input = document.createElement('input');
input.type = 'file';
input.id = 'wechat-contact-avatar-input';
input.accept = 'image/*';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file || pendingAvatarContactIndex < 0) return;
try {
const reader = new FileReader();
reader.onload = function(event) {
const settings = extension_settings[extensionName];
if (settings.contacts[pendingAvatarContactIndex]) {
settings.contacts[pendingAvatarContactIndex].avatar = event.target.result;
saveSettingsDebounced();
refreshContactsList();
showToast('角色头像已更换');
}
};
reader.readAsDataURL(file);
} catch (err) {
console.error('更换角色头像失败:', err);
showToast('更换头像失败: ' + err.message, '❌');
}
e.target.value = '';
pendingAvatarContactIndex = -1;
});
}
input.click();
}
// 当前聊天的联系人索引
let currentChatIndex = -1;
// 打开聊天界面
function openChat(contactIndex) {
const settings = extension_settings[extensionName];
const contact = settings.contacts[contactIndex];
if (!contact) return;
currentChatIndex = contactIndex;
// 隐藏主页面,显示聊天页面
document.getElementById('wechat-main-content').classList.add('hidden');
document.getElementById('wechat-chat-page').classList.remove('hidden');
// 设置标题
document.getElementById('wechat-chat-title').textContent = contact.name;
// 显示聊天历史或空白
const messagesContainer = document.getElementById('wechat-chat-messages');
const chatHistory = contact.chatHistory || [];
if (chatHistory.length === 0) {
// 空白聊天界面
messagesContainer.innerHTML = '';
} else {
// 渲染聊天历史
messagesContainer.innerHTML = renderChatHistory(contact, chatHistory);
// 绑定历史语音消息的点击事件
bindVoiceBubbleEvents(messagesContainer);
}
// 滚动到底部
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 绑定语音消息点击事件
function bindVoiceBubbleEvents(container) {
const voiceBubbles = container.querySelectorAll('.wechat-voice-bubble:not([data-bound])');
voiceBubbles.forEach(bubble => {
bubble.setAttribute('data-bound', 'true');
bubble.addEventListener('click', () => {
const voiceId = bubble.dataset.voiceId;
const textEl = document.getElementById(voiceId);
if (textEl) {
textEl.classList.toggle('hidden');
bubble.classList.toggle('expanded');
}
});
});
}
// 切换页面显示
function showPage(pageId) {
['wechat-main-content', 'wechat-add-page', 'wechat-chat-page', 'wechat-settings-page', 'wechat-me-page', 'wechat-favorites-page', 'wechat-service-page'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.classList.toggle('hidden', id !== pageId);
}
});
// 如果进入"我"页面,更新用户信息
if (pageId === 'wechat-me-page') {
updateMePageInfo();
}
// 如果进入收藏页面,刷新列表
if (pageId === 'wechat-favorites-page') {
refreshFavoritesList();
}
// 如果进入服务页面,更新钱包金额显示
if (pageId === 'wechat-service-page') {
const settings = extension_settings[extensionName];
const amountEl = document.getElementById('wechat-wallet-amount');
if (amountEl) {
const amount = settings.walletAmount || '5773.89';
amountEl.textContent = amount.startsWith('¥') ? amount : `¥${amount}`;
}
}
}
// 更新"我"页面用户信息
function updateMePageInfo() {
try {
const context = getContext();
if (context) {
const userName = context.name1 || 'User';
const nameEl = document.getElementById('wechat-me-name');
const avatarEl = document.getElementById('wechat-me-avatar');
if (nameEl) nameEl.textContent = userName;
if (avatarEl) {
avatarEl.innerHTML = getUserAvatarHTML();
}
}
} catch (err) {
console.error('更新用户信息失败:', err);
}
}
// 刷新收藏/世界书列表
function refreshFavoritesList(filter = 'all') {
const settings = extension_settings[extensionName];
const listEl = document.getElementById('wechat-favorites-list');
if (!listEl) return;
// 关闭所有展开的面板
closeUserPersonaPanel();
closeEntryPanel();
const items = [];
// 收集用户设定 - 支持多条目
if (filter === 'all' || filter === 'user') {
// 初始化用户设定数组
if (!settings.userPersonas) {
settings.userPersonas = [];
// 迁移旧数据
if (settings.userPersona) {
settings.userPersonas.push({
id: Date.now(),
name: settings.userPersona.name || '用户设定',
content: settings.userPersona.customContent || settings.userPersona.content || '',
enabled: settings.userPersona.enabled !== false
});
}
}
// 从酒馆读取用户设定(作为默认项,如果没有自定义的话)
const stPersona = getUserPersonaFromST();
if (stPersona && settings.userPersonas.length === 0) {
settings.userPersonas.push({
id: Date.now(),
name: stPersona.name || '用户',
content: stPersona.description || '',
enabled: true,
fromST: true
});
}
// 添加所有用户设定条目
settings.userPersonas.forEach((persona, idx) => {
items.push({
type: 'user-entry',
personaIdx: idx,
id: persona.id,
name: persona.name || '用户设定',
content: persona.content || '',
enabled: persona.enabled !== false
});
});
}
// 收集角色卡的世界书条目 - 按角色分组
if (filter === 'all' || filter === 'character') {
settings.contacts.forEach((contact, contactIdx) => {
if (contact.rawData?.data?.character_book?.entries?.length > 0) {
const entries = contact.rawData.data.character_book.entries;
// 先添加角色卡头部
items.push({
type: 'character-header',
source: contact.name,
contactIdx: contactIdx,
entriesCount: entries.length,
collapsed: contact.lorebookCollapsed !== false // 默认折叠
});
// 再添加条目(如果未折叠)
if (contact.lorebookCollapsed === false) {
entries.forEach((entry, idx) => {
items.push({
type: 'character',
source: contact.name,
contactIdx: contactIdx,
entryIdx: idx,
title: entry.comment || entry.keys?.[0] || `条目 ${idx + 1}`,
content: entry.content || '',
keys: entry.keys || [],
enabled: entry.enabled !== false
});
});
}
}
});
}
// 收集选择的世界书条目(全局世界书)
if (filter === 'all' || filter === 'global') {
(settings.selectedLorebooks || []).forEach((lb, lbIdx) => {
// 跳过角色卡自带的世界书
if (lb.fromCharacter) return;
// 显示世界书本身
items.push({
type: 'global-header',
source: lb.name,
lorebookIdx: lbIdx,
title: lb.name,
date: lb.addedTime || '',
entriesCount: (lb.entries || []).length,
enabled: lb.enabled !== false
});
// 显示世界书下的条目
(lb.entries || []).forEach((entry, entryIdx) => {
items.push({
type: 'global',
source: lb.name,
lorebookIdx: lbIdx,
entryIdx: entryIdx,
title: entry.comment || entry.keys?.[0] || entry.key?.[0] || `条目 ${entryIdx + 1}`,
content: entry.content || '',
keys: entry.keys || entry.key || [],
enabled: entry.enabled !== false && entry.disable !== true
});
});
});
}
if (items.length === 0) {
const emptyMsg = filter === 'user'
? '暂无用户设定
请在酒馆中设置用户人格'
: '暂无收藏
导入角色卡或添加世界书';
listEl.innerHTML = `
`;
return;
}
listEl.innerHTML = items.map((item, idx) => {
if (item.type === 'user-entry') {
// 用户设定条目(带展开面板容器)
const isEnabled = item.enabled !== false;
const previewText = (item.content || '').substring(0, 40) + ((item.content || '').length > 40 ? '...' : '');
return `
`;
} else if (item.type === 'character-header') {
// 角色卡世界书标题行(可折叠)
const collapseIcon = item.collapsed ? '▶' : '▼';
return `
`;
} else if (item.type === 'global-header') {
// 全局世界书标题行
const isEnabled = item.enabled !== false;
return `
`;
} else {
// 条目行(细条)- 带展开面板容器
const enabledClass = item.enabled ? '' : 'disabled';
const typeTag = item.type === 'character' ? '角色' : '全局';
const entryId = `entry-${item.type}-${item.contactIdx ?? 'lb'}-${item.lorebookIdx ?? ''}-${item.entryIdx}`;
return `
`;
}
}).join('');
// 如果是用户标签,在底部添加"新建"按钮
if (filter === 'user') {
listEl.innerHTML += `
`;
}
// 绑定用户设定条目点击事件(展开面板)
listEl.querySelectorAll('.wechat-favorites-user-entry').forEach(entry => {
entry.addEventListener('click', (e) => {
if (e.target.closest('.wechat-toggle')) return;
const personaIdx = parseInt(entry.dataset.personaIdx);
toggleUserPersonaPanel(personaIdx);
});
});
// 绑定用户设定开关
listEl.querySelectorAll('.wechat-favorites-user-entry .wechat-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
});
const checkbox = toggle.querySelector('input[type="checkbox"]');
checkbox?.addEventListener('change', (e) => {
const personaIdx = parseInt(toggle.dataset.personaIdx);
if (settings.userPersonas && settings.userPersonas[personaIdx]) {
settings.userPersonas[personaIdx].enabled = e.target.checked;
saveSettingsDebounced();
}
});
});
// 绑定新建按钮(新建使用弹窗)
document.getElementById('wechat-add-persona-btn')?.addEventListener('click', () => {
showNewPersonaModal(); // 新建使用弹窗
});
// 绑定角色卡世界书头部点击(展开/折叠)
listEl.querySelectorAll('.wechat-favorites-character-header').forEach(header => {
header.addEventListener('click', () => {
const contactIdx = parseInt(header.dataset.contactIdx);
const contact = settings.contacts[contactIdx];
if (contact) {
// 切换折叠状态
contact.lorebookCollapsed = contact.lorebookCollapsed === false ? true : false;
saveSettingsDebounced();
refreshFavoritesList(filter);
}
});
});
// 绑定条目点击事件(点击非toggle区域展开面板)
listEl.querySelectorAll('.wechat-favorites-entry:not(.wechat-favorites-user-entry)').forEach(entry => {
entry.addEventListener('click', (e) => {
// 如果点击的是toggle,不展开面板
if (e.target.closest('.wechat-toggle')) return;
const type = entry.dataset.type;
const entryIdx = parseInt(entry.dataset.entryIdx);
const entryId = entry.dataset.entryId;
if (type === 'character') {
const contactIdx = parseInt(entry.dataset.contactIdx);
toggleEntryPanel(type, contactIdx, null, entryIdx, entryId);
} else if (type === 'global') {
const lbIdx = parseInt(entry.dataset.lbIdx);
toggleEntryPanel(type, null, lbIdx, entryIdx, entryId);
}
});
});
// 绑定删除按钮
listEl.querySelectorAll('.wechat-favorites-delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const lbIdx = parseInt(btn.dataset.lbIdx);
if (confirm('确定要删除这个世界书吗?')) {
settings.selectedLorebooks.splice(lbIdx, 1);
saveSettingsDebounced();
refreshFavoritesList(filter);
}
});
});
// 绑定启用/禁用开关(世界书整体开关)
listEl.querySelectorAll('.wechat-favorites-header .wechat-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
});
const checkbox = toggle.querySelector('input[type="checkbox"]');
checkbox?.addEventListener('change', (e) => {
const lbIdx = parseInt(toggle.dataset.lbIdx);
if (settings.selectedLorebooks[lbIdx]) {
settings.selectedLorebooks[lbIdx].enabled = e.target.checked;
saveSettingsDebounced();
}
});
});
// 绑定条目开关
listEl.querySelectorAll('.wechat-favorites-entry .wechat-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
});
const checkbox = toggle.querySelector('input[type="checkbox"]');
checkbox?.addEventListener('change', (e) => {
const type = toggle.dataset.type;
const entryIdx = parseInt(toggle.dataset.entryIdx);
if (type === 'character') {
const contactIdx = parseInt(toggle.dataset.contactIdx);
const contact = settings.contacts[contactIdx];
if (contact?.rawData?.data?.character_book?.entries?.[entryIdx]) {
contact.rawData.data.character_book.entries[entryIdx].enabled = e.target.checked;
saveSettingsDebounced();
}
} else if (type === 'global') {
const lbIdx = parseInt(toggle.dataset.lbIdx);
if (settings.selectedLorebooks[lbIdx]?.entries?.[entryIdx]) {
settings.selectedLorebooks[lbIdx].entries[entryIdx].enabled = e.target.checked;
saveSettingsDebounced();
}
}
// 更新条目样式
const entryEl = toggle.closest('.wechat-favorites-entry');
if (entryEl) {
entryEl.classList.toggle('disabled', !e.target.checked);
}
});
});
}
// 当前展开的条目ID
let currentExpandedEntryId = null;
// 切换条目展开面板
function toggleEntryPanel(type, contactIdx, lbIdx, entryIdx, entryId) {
const settings = extension_settings[extensionName];
const panel = document.getElementById(`${entryId}-panel`);
const entryEl = document.querySelector(`.wechat-favorites-entry[data-entry-id="${entryId}"]`);
if (!panel) return;
let entry, source;
if (type === 'character') {
const contact = settings.contacts[contactIdx];
entry = contact?.rawData?.data?.character_book?.entries?.[entryIdx];
source = contact?.name || '未知角色';
} else {
const lb = settings.selectedLorebooks[lbIdx];
entry = lb?.entries?.[entryIdx];
source = lb?.name || '未知世界书';
}
if (!entry) {
showToast('无法找到条目', '❌');
return;
}
// 如果已经展开,则收起
if (currentExpandedEntryId === entryId) {
closeEntryPanel();
return;
}
// 先关闭其他展开的面板
if (currentExpandedEntryId) {
closeEntryPanel();
}
currentExpandedEntryId = entryId;
// 填充面板内容
panel.innerHTML = `
`;
// 显示面板
panel.classList.add('wechat-lorebook-panel-show');
entryEl?.classList.add('wechat-favorites-item-expanded');
// 绑定事件
bindEntryPanelEvents(type, contactIdx, lbIdx, entryIdx, entryId);
}
// 关闭条目展开面板
function closeEntryPanel() {
if (!currentExpandedEntryId) return;
const panel = document.getElementById(`${currentExpandedEntryId}-panel`);
const entryEl = document.querySelector(`.wechat-favorites-entry[data-entry-id="${currentExpandedEntryId}"]`);
if (panel) {
panel.classList.remove('wechat-lorebook-panel-show');
panel.innerHTML = '';
}
entryEl?.classList.remove('wechat-favorites-item-expanded');
currentExpandedEntryId = null;
}
// 绑定条目面板事件
function bindEntryPanelEvents(type, contactIdx, lbIdx, entryIdx, entryId) {
const settings = extension_settings[extensionName];
// 收起按钮
document.getElementById('wechat-entry-panel-close')?.addEventListener('click', () => {
closeEntryPanel();
});
// 同步到酒馆
document.getElementById('wechat-entry-sync-btn')?.addEventListener('click', async () => {
const keys = document.getElementById('wechat-entry-edit-keys')?.value.trim();
const comment = document.getElementById('wechat-entry-edit-comment')?.value.trim();
const content = document.getElementById('wechat-entry-edit-content')?.value.trim();
if (!content) {
showToast('请先填写内容', '⚠️');
return;
}
try {
if (type === 'global') {
const lb = settings.selectedLorebooks[lbIdx];
if (lb && lb.name) {
await syncLorebookEntryToTavern(lb.name, entryIdx, {
keys: keys.split(/[,,]/).map(k => k.trim()).filter(k => k),
comment: comment,
content: content
});
showToast('已同步到酒馆');
}
} else {
showToast('角色卡条目暂不支持同步', '⚠️');
}
} catch (err) {
console.error('同步失败:', err);
showToast('同步失败: ' + err.message, '❌');
}
});
// 保存
document.getElementById('wechat-entry-save-btn')?.addEventListener('click', () => {
const keys = document.getElementById('wechat-entry-edit-keys')?.value.trim();
const comment = document.getElementById('wechat-entry-edit-comment')?.value.trim();
const content = document.getElementById('wechat-entry-edit-content')?.value.trim();
let entry;
if (type === 'character') {
const contact = settings.contacts[contactIdx];
entry = contact?.rawData?.data?.character_book?.entries?.[entryIdx];
} else {
entry = settings.selectedLorebooks[lbIdx]?.entries?.[entryIdx];
}
if (entry) {
entry.keys = keys.split(/[,,]/).map(k => k.trim()).filter(k => k);
entry.key = entry.keys; // 兼容两种格式
entry.comment = comment;
entry.content = content;
saveSettingsDebounced();
showToast('已保存');
closeEntryPanel();
// 刷新列表
const activeTab = document.querySelector('.wechat-favorites-tab.active');
refreshFavoritesList(activeTab?.dataset.tab || 'all');
}
});
}
// 同步世界书条目到酒馆
async function syncLorebookEntryToTavern(lorebookName, entryIdx, entryData) {
try {
if (typeof loadWorldInfo !== 'function' || typeof saveWorldInfo !== 'function') {
throw new Error('世界书API不可用');
}
const worldData = await loadWorldInfo(lorebookName);
if (!worldData?.entries) {
throw new Error('无法加载世界书数据');
}
// 更新条目
if (worldData.entries[entryIdx]) {
worldData.entries[entryIdx].key = entryData.keys;
worldData.entries[entryIdx].comment = entryData.comment;
worldData.entries[entryIdx].content = entryData.content;
await saveWorldInfo(lorebookName, worldData);
} else {
throw new Error('找不到对应的条目');
}
} catch (err) {
console.error('同步世界书条目失败:', err);
throw err;
}
}
// 从酒馆获取用户设定
function getUserPersonaFromST() {
try {
// SillyTavern 暴露的全局变量
let name = '';
let description = '';
let avatar = '';
// 方法1: 从 getContext 获取
const context = getContext();
if (context) {
name = context.name1 || '';
avatar = context.user_avatar || '';
}
// 方法2: 从 name1 全局变量获取
if (!name && typeof name1 !== 'undefined') {
name = name1;
}
// 方法3: 从 power_user.persona_description 获取描述
if (typeof power_user !== 'undefined') {
if (power_user.persona_description) {
description = power_user.persona_description;
}
// 从 personas 系统获取当前 persona
if (power_user.personas && power_user.default_persona) {
const currentPersona = power_user.default_persona;
if (power_user.personas[currentPersona]) {
description = power_user.personas[currentPersona];
if (!name) name = currentPersona;
}
}
}
// 方法4: 尝试从 user_avatar 获取名字
if (!name && typeof user_avatar !== 'undefined') {
name = user_avatar.replace(/\.[^/.]+$/, ''); // 去掉扩展名
}
// 方法5: 从 DOM 获取当前 persona 描述
if (!description) {
const personaDescEl = document.querySelector('#persona_description');
if (personaDescEl && personaDescEl.value) {
description = personaDescEl.value;
}
}
if (name || description) {
return { name, description, avatar };
}
} catch (err) {
console.error('获取用户设定失败:', err);
}
return null;
}
// 当前展开的用户设定索引
let currentExpandedPersonaIdx = -1;
// 切换用户设定展开面板
function toggleUserPersonaPanel(personaIdx) {
const settings = extension_settings[extensionName];
const panel = document.getElementById(`wechat-persona-panel-${personaIdx}`);
const entryEl = document.querySelector(`.wechat-favorites-user-entry[data-persona-idx="${personaIdx}"]`);
if (!panel || !settings.userPersonas?.[personaIdx]) return;
// 如果已经展开,则收起
if (currentExpandedPersonaIdx === personaIdx) {
closeUserPersonaPanel();
return;
}
// 先关闭其他展开的面板
if (currentExpandedPersonaIdx >= 0) {
closeUserPersonaPanel();
}
currentExpandedPersonaIdx = personaIdx;
const persona = settings.userPersonas[personaIdx];
// 填充面板内容
panel.innerHTML = `
💡 启用的设定会作为用户背景发送给AI
`;
// 显示面板
panel.classList.add('wechat-lorebook-panel-show');
entryEl?.classList.add('wechat-favorites-item-expanded');
// 绑定事件
bindPersonaPanelEvents(personaIdx);
}
// 关闭用户设定展开面板
function closeUserPersonaPanel() {
if (currentExpandedPersonaIdx < 0) return;
const panel = document.getElementById(`wechat-persona-panel-${currentExpandedPersonaIdx}`);
const entryEl = document.querySelector(`.wechat-favorites-user-entry[data-persona-idx="${currentExpandedPersonaIdx}"]`);
if (panel) {
panel.classList.remove('wechat-lorebook-panel-show');
panel.innerHTML = '';
}
entryEl?.classList.remove('wechat-favorites-item-expanded');
currentExpandedPersonaIdx = -1;
}
// 绑定用户设定面板事件
function bindPersonaPanelEvents(personaIdx) {
const settings = extension_settings[extensionName];
// 收起按钮
document.getElementById('wechat-persona-panel-close')?.addEventListener('click', () => {
closeUserPersonaPanel();
});
// 从酒馆导入
document.getElementById('wechat-persona-import-btn')?.addEventListener('click', () => {
const stPersona = getUserPersonaFromST();
if (stPersona) {
const nameInput = document.getElementById('wechat-persona-edit-name');
const contentInput = document.getElementById('wechat-persona-edit-content');
if (nameInput) nameInput.value = stPersona.name || '';
if (contentInput) contentInput.value = stPersona.description || '';
showToast('已从酒馆导入用户设定');
} else {
showToast('未找到酒馆用户设定', '⚠️');
}
});
// 同步到酒馆
document.getElementById('wechat-persona-sync-btn')?.addEventListener('click', () => {
const name = document.getElementById('wechat-persona-edit-name')?.value.trim();
const content = document.getElementById('wechat-persona-edit-content')?.value.trim();
if (!content) {
showToast('请先填写内容', '⚠️');
return;
}
syncPersonaToTavern(name, content);
});
// 删除
document.getElementById('wechat-persona-delete-btn')?.addEventListener('click', () => {
if (confirm('确定要删除这个用户设定吗?')) {
settings.userPersonas.splice(personaIdx, 1);
saveSettingsDebounced();
closeUserPersonaPanel();
refreshFavoritesList('user');
}
});
// 保存
document.getElementById('wechat-persona-save-btn')?.addEventListener('click', () => {
const name = document.getElementById('wechat-persona-edit-name')?.value.trim();
const content = document.getElementById('wechat-persona-edit-content')?.value.trim();
if (!name) {
showToast('请输入名称', '⚠️');
return;
}
settings.userPersonas[personaIdx].name = name;
settings.userPersonas[personaIdx].content = content;
saveSettingsDebounced();
showToast('已保存');
closeUserPersonaPanel();
refreshFavoritesList('user');
});
}
// 同步用户设定到酒馆
function syncPersonaToTavern(name, content) {
try {
// 检查 power_user 是否可用
if (typeof power_user === 'undefined') {
showToast('无法访问酒馆设置', '❌');
return;
}
// 更新 persona_description
power_user.persona_description = content;
// 如果有 name 且 personas 系统可用,也更新它
if (name && power_user.personas && power_user.default_persona) {
power_user.personas[power_user.default_persona] = content;
}
// 更新 DOM 中的输入框(如果存在)
const personaDescEl = document.querySelector('#persona_description');
if (personaDescEl) {
personaDescEl.value = content;
personaDescEl.dispatchEvent(new Event('input', { bubbles: true }));
}
// 触发酒馆保存
if (typeof SillyTavern !== 'undefined' && SillyTavern.saveSettingsDebounced) {
SillyTavern.saveSettingsDebounced();
} else if (typeof saveSettingsDebounced !== 'undefined') {
saveSettingsDebounced();
}
showToast('已同步到酒馆');
} catch (err) {
console.error('同步到酒馆失败:', err);
showToast('同步失败: ' + err.message, '❌');
}
}
// 显示新建用户设定弹窗
function showNewPersonaModal() {
const settings = extension_settings[extensionName];
// 初始化数组
if (!settings.userPersonas) {
settings.userPersonas = [];
}
const modal = document.createElement('div');
modal.className = 'wechat-modal';
modal.id = 'wechat-user-persona-modal';
modal.innerHTML = `
新建用户设定
💡 启用的设定会作为用户背景发送给AI
`;
document.body.appendChild(modal);
// 取消
modal.querySelector('#wechat-user-persona-cancel').addEventListener('click', () => {
modal.remove();
});
// 从酒馆导入
modal.querySelector('#wechat-user-persona-import').addEventListener('click', () => {
const stPersona = getUserPersonaFromST();
if (stPersona) {
document.getElementById('wechat-user-persona-name').value = stPersona.name || '';
document.getElementById('wechat-user-persona-content').value = stPersona.description || '';
showToast('已从酒馆导入用户设定');
} else {
showToast('未找到酒馆用户设定', '⚠️');
}
});
// 保存
modal.querySelector('#wechat-user-persona-save').addEventListener('click', () => {
const name = document.getElementById('wechat-user-persona-name').value.trim();
const content = document.getElementById('wechat-user-persona-content').value.trim();
if (!name) {
showToast('请输入名称', '⚠️');
return;
}
// 新建
settings.userPersonas.push({
id: Date.now(),
name: name,
content: content,
enabled: true
});
saveSettingsDebounced();
refreshFavoritesList('user');
modal.remove();
});
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
// 获取酒馆世界书列表
async function getLorebooksList() {
try {
const response = await fetch('/api/worldinfo/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({})
});
if (response.ok) {
return await response.json();
}
} catch (err) {
console.error('获取世界书列表失败:', err);
}
return [];
}
// 显示世界书选择弹窗
async function showLorebookModal() {
const modal = document.getElementById('wechat-lorebook-modal');
const listEl = document.getElementById('wechat-lorebook-list');
listEl.innerHTML = '加载中...
';
modal.classList.remove('hidden');
try {
let lorebooks = [];
// SillyTavern 在前端暴露了 world_names 全局变量
if (typeof world_names !== 'undefined' && Array.isArray(world_names)) {
lorebooks = [...world_names];
}
if (lorebooks.length === 0) {
listEl.innerHTML = `
暂无世界书
请在酒馆中创建世界书后刷新
`;
return;
}
// 过滤重复和空值
lorebooks = [...new Set(lorebooks.filter(Boolean))];
listEl.innerHTML = lorebooks.map(name => `
`).join('');
// 绑定点击事件
listEl.querySelectorAll('.wechat-lorebook-item').forEach(item => {
item.addEventListener('click', async () => {
const name = item.dataset.name;
await loadLorebookEntries(name);
modal.classList.add('hidden');
});
});
} catch (err) {
console.error('获取世界书失败:', err);
listEl.innerHTML = '加载失败: ' + err.message + '
';
}
}
// 加载世界书条目
async function loadLorebookEntries(lorebookName) {
const settings = extension_settings[extensionName];
if (!settings.selectedLorebooks) {
settings.selectedLorebooks = [];
}
// 检查是否已添加
if (settings.selectedLorebooks.some(lb => lb.name === lorebookName)) {
showToast('该世界书已在收藏中', '⚠️');
return;
}
let entries = [];
try {
// 使用 SillyTavern 的 loadWorldInfo 函数加载世界书数据
const data = await loadWorldInfo(lorebookName);
if (data && data.entries) {
entries = Object.values(data.entries);
}
} catch (err) {
console.error('加载世界书条目失败:', err);
}
const now = new Date();
const timeStr = `${(now.getMonth() + 1)}月${now.getDate()}日`;
settings.selectedLorebooks.push({
name: lorebookName,
addedTime: timeStr,
entries: entries
});
saveSettingsDebounced();
refreshFavoritesList();
if (entries.length > 0) {
showToast(`已添加: ${lorebookName} (${entries.length}条)`);
} else {
showToast(`已添加: ${lorebookName}`);
}
}
// 添加世界书到收藏
function addLorebookToFavorites(name) {
const settings = extension_settings[extensionName];
if (!settings.selectedLorebooks) {
settings.selectedLorebooks = [];
}
// 检查是否已添加
if (settings.selectedLorebooks.some(lb => lb.name === name)) {
showToast('该世界书已在收藏中', '⚠️');
return;
}
const now = new Date();
const timeStr = `${(now.getMonth() + 1)}月${now.getDate()}日`;
settings.selectedLorebooks.push({
name: name,
addedTime: timeStr
});
saveSettingsDebounced();
refreshFavoritesList();
showToast(`已添加: ${name}`);
}
// ========== 总结功能相关函数 ==========
// 世界书名称(固定)
const LOREBOOK_NAME = '【可乐】聊天记录';
// 获取当前应该是第几杯
function getNextCupNumber() {
const settings = extension_settings[extensionName];
const selectedLorebooks = settings.selectedLorebooks || [];
// 查找【可乐】聊天记录世界书
const lorebook = selectedLorebooks.find(lb => lb.name === LOREBOOK_NAME);
if (lorebook && lorebook.entries) {
return lorebook.entries.length + 1;
}
return 1;
}
// 标记前缀
const SUMMARY_MARKER_PREFIX = '🧊 可乐已加冰_';
// 收集所有联系人的聊天记录(只收集最后一个标记之后的内容)
function collectAllChatHistory() {
const settings = extension_settings[extensionName];
const contacts = settings.contacts || [];
const allChats = [];
contacts.forEach(contact => {
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({
contactName: contact.name,
contactDescription: contact.description || '',
messages: realMessages.map(msg => ({
role: msg.role,
content: msg.content,
time: msg.time || '',
isVoice: msg.isVoice || false
}))
});
}
});
return allChats;
}
// 在所有联系人的聊天记录中插入标记
function insertSummaryMarker(cupNumber) {
const settings = extension_settings[extensionName];
const contacts = settings.contacts || [];
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 => {
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
});
}
}
});
saveSettingsDebounced();
}
// 生成总结提示词(每次只生成一杯,记录感情变化)
function generateSummaryPrompt(allChats, cupNumber) {
let prompt = `分析以下微信聊天记录,记录感情关系的变化。
【任务】
这是第${cupNumber}杯记录。请总结这段对话中感情关系的发展和变化。
【记录要点】
- 感情状态的变化(亲密度、信任度、态度转变等)
- 关系中的重要事件(约定、承诺、矛盾、和解等)
- 双方互动的关键内容
- 只记录事实,不做主观评价
【输出要求】
- 只输出一个条目的JSON
- 不要使用markdown代码块
- 直接以 { 开头,以 } 结尾
【JSON格式】
{"keys":["关键词1","关键词2"],"content":"感情变化记录","comment":"第${cupNumber}杯"}
【示例】
{"keys":["表白","确认关系"],"content":"小美向用户表白,用户接受。两人确认恋爱关系,约定周末见面。小美表现得很开心,多次说想用户。","comment":"第1杯"}
【聊天记录】
`;
allChats.forEach(chat => {
prompt += `\n[与${chat.contactName}的对话]\n`;
chat.messages.slice(-300).forEach(msg => { // 取最近300条消息
const speaker = msg.role === 'user' ? '用户' : chat.contactName;
prompt += `${speaker}: ${msg.content}\n`;
});
});
prompt += `\n总结这段对话中的感情变化,输出第${cupNumber}杯的JSON:`;
return prompt;
}
// 调用总结API
async function callSummaryAPI(prompt) {
const settings = extension_settings[extensionName];
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 || '';
console.log('[可乐不加冰] AI原始响应:', content);
// 尝试解析JSON(多种方式)
const parseJSON = (str) => {
// 方法1: 直接解析
try {
const result = JSON.parse(str);
console.log('[可乐不加冰] 方法1成功: 直接解析');
return result;
} catch (e) {}
// 方法2: 移除 markdown 代码块后解析
try {
const cleaned = str.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
const result = JSON.parse(cleaned);
console.log('[可乐不加冰] 方法2成功: 移除markdown');
return result;
} catch (e) {}
// 方法3: 从文本中提取 JSON 对象(找第一个 { 到最后一个 })
try {
const firstBrace = str.indexOf('{');
const lastBrace = str.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
const jsonPart = str.substring(firstBrace, lastBrace + 1);
const result = JSON.parse(jsonPart);
console.log('[可乐不加冰] 方法3成功: 提取JSON部分');
return result;
}
} catch (e) {}
// 方法4: 尝试匹配 entries 数组(更宽松)
try {
// 找到 "entries" 后的数组内容
const match = str.match(/"entries"\s*:\s*\[/);
if (match) {
const startIdx = str.indexOf('[', match.index);
let bracketCount = 1;
let endIdx = startIdx + 1;
while (endIdx < str.length && bracketCount > 0) {
if (str[endIdx] === '[') bracketCount++;
if (str[endIdx] === ']') bracketCount--;
endIdx++;
}
const arrayContent = str.substring(startIdx, endIdx);
const result = JSON.parse(`{"entries":${arrayContent}}`);
console.log('[可乐不加冰] 方法4成功: 提取entries数组');
return result;
}
} catch (e) {}
// 方法5: 尝试修复常见JSON错误
try {
let fixed = str
.replace(/,\s*}/g, '}')
.replace(/,\s*]/g, ']')
.replace(/[\u201c\u201d]/g, '"') // 中文引号
.replace(/'/g, '"');
const firstBrace = fixed.indexOf('{');
const lastBrace = fixed.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
const result = JSON.parse(fixed.substring(firstBrace, lastBrace + 1));
console.log('[可乐不加冰] 方法5成功: 修复JSON格式');
return result;
}
} catch (e) {}
// 方法6: 从非JSON文本中提取结构化信息
try {
const entries = [];
const blocks = str.split(/\n\n+|\d+\.\s+/);
for (const block of blocks) {
if (!block.trim()) continue;
let keys = [];
let content = '';
let comment = '';
// 尝试提取关键词
const keyMatch = block.match(/[关键词keys]+[::\s]+([^\n]+)/i);
if (keyMatch) keys = keyMatch[1].split(/[,,、]/g).map(k => k.trim()).filter(k => k);
// 尝试提取内容
const contentMatch = block.match(/[内容content]+[::\s]+([^\n]+)/i);
if (contentMatch) content = contentMatch[1].trim();
// 尝试提取标题
const titleMatch = block.match(/[标题title评论comment]+[::\s]+([^\n]+)/i);
if (titleMatch) comment = titleMatch[1].trim();
// 如果有足够信息,创建条目
if ((keys.length > 0 || comment) && content) {
entries.push({
keys: keys.length > 0 ? keys : [comment || '关键词'],
content: content,
comment: comment || keys[0] || '条目'
});
}
}
if (entries.length > 0) {
console.log('[可乐不加冰] 方法6成功: 从文本提取');
return { entries };
}
} catch (e) {}
return null;
};
const parsed = parseJSON(content);
if (parsed) {
// 现在返回单个条目格式(不是 entries 数组)
// 如果解析结果有 keys 和 content,说明是单条目
if (parsed.keys && parsed.content) {
console.log('[可乐不加冰] 解析成功: 单条目格式');
return parsed;
}
// 兼容旧的 entries 数组格式(取第一个)
if (parsed.entries && parsed.entries.length > 0) {
console.log('[可乐不加冰] 解析成功: entries数组格式,取第一个');
return parsed.entries[0];
}
// 如果是数组,取第一个
if (Array.isArray(parsed) && parsed.length > 0) {
console.log('[可乐不加冰] 解析成功: 数组格式,取第一个');
return parsed[0];
}
}
// 最终降级:如果内容不为空,创建一个基本条目
console.error('[可乐不加冰] 所有解析方法失败,原始内容:', content);
if (content && content.trim().length > 20) {
console.log('[可乐不加冰] 使用降级方案:创建基本条目');
// 提取有意义的文本片段作为关键词
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
const uniqueWords = [...new Set(words)].slice(0, 5);
return {
keys: uniqueWords.length > 0 ? uniqueWords : ['聊天记录'],
content: content.substring(0, 800).replace(/```[\s\S]*?```/g, '').trim(),
comment: '感情记录'
};
}
throw new Error('AI返回内容为空或无法解析');
}
// 保存单个条目到收藏(追加到已有世界书)
function saveEntryToFavorites(entry, cupNumber) {
const settings = extension_settings[extensionName];
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')}`;
// 查找已有的【可乐】聊天记录世界书
let lorebook = settings.selectedLorebooks.find(lb => lb.name === LOREBOOK_NAME);
if (!lorebook) {
// 不存在则创建新的
lorebook = {
name: LOREBOOK_NAME,
addedTime: timeStr,
entries: [],
enabled: true,
fromSummary: true
};
settings.selectedLorebooks.push(lorebook);
}
// 格式化新条目
const newEntry = {
uid: cupNumber - 1,
keys: entry.keys || [],
content: entry.content || '',
comment: entry.comment || `第${cupNumber}杯`,
enabled: true,
case_sensitive: false,
priority: 10,
id: cupNumber - 1,
addedTime: timeStr
};
// 追加条目
lorebook.entries.push(newEntry);
lorebook.lastUpdated = timeStr;
saveSettingsDebounced();
return lorebook;
}
// 同步单个条目到酒馆世界书(追加模式)
async function syncEntryToSillyTavern(entry, cupNumber) {
try {
const name = LOREBOOK_NAME;
// 构建单个条目格式
const newEntry = {
uid: cupNumber - 1,
key: entry.keys || [],
keysecondary: [],
comment: entry.comment || `第${cupNumber}杯`,
content: entry.content || '',
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: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: false,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0,
sticky: null,
cooldown: null,
delay: null
};
console.log('[可乐不加冰] 准备同步第', cupNumber, '杯到酒馆');
// 检查世界书是否已存在
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (!worldExists) {
// 世界书不存在,创建新的
console.log('[可乐不加冰] 世界书不存在,创建新的...');
if (typeof createNewWorldInfo === 'function') {
await createNewWorldInfo(name);
await sleep(500);
}
}
// 加载现有世界书数据
let worldInfo = { entries: {} };
if (typeof loadWorldInfo === 'function') {
const existingData = await loadWorldInfo(name);
if (existingData && existingData.entries) {
worldInfo = existingData;
}
}
// 追加新条目(使用 cupNumber-1 作为 key,确保不会覆盖)
const entryKey = cupNumber - 1;
worldInfo.entries[entryKey] = newEntry;
console.log('[可乐不加冰] 当前条目数:', Object.keys(worldInfo.entries).length);
// 保存世界书
if (typeof saveWorldInfo === 'function') {
await saveWorldInfo(name, worldInfo);
console.log('[可乐不加冰] 保存完成');
// 验证
await sleep(300);
const verifyData = await loadWorldInfo(name);
const savedCount = verifyData?.entries ? Object.keys(verifyData.entries).length : 0;
console.log('[可乐不加冰] 验证: 条目数 =', savedCount);
return true;
}
throw new Error('saveWorldInfo 函数不可用');
} catch (err) {
console.error('[可乐不加冰] 同步到酒馆失败:', err);
throw err;
}
}
// 执行总结主函数
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 {
// 步骤1: 收集聊天记录
updateProgress('📋 收集聊天记录...');
const allChats = collectAllChatHistory();
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);
// 步骤2: 获取当前杯数
const cupNumber = getNextCupNumber();
updateProgress(`🍵 准备生成第${cupNumber}杯...`);
await sleep(300);
// 步骤3: 生成提示词并调用API
updateProgress('🤖 调用AI分析感情变化...');
const prompt = generateSummaryPrompt(allChats, cupNumber);
const entry = await callSummaryAPI(prompt);
updateProgress(`✨ 已生成第${cupNumber}杯记录`);
await sleep(500);
// 步骤4: 保存到收藏(追加到【可乐】聊天记录世界书)
updateProgress('💾 保存到收藏...');
saveEntryToFavorites(entry, cupNumber);
await sleep(300);
// 步骤5: 同步到酒馆(可选,失败不影响使用)
updateProgress('📤 尝试同步到酒馆...');
try {
await syncEntryToSillyTavern(entry, cupNumber);
updateProgress(`✅ 完成!第${cupNumber}杯已保存`);
} catch (syncErr) {
// 同步失败但本地保存成功,这是可以接受的
console.error('同步到酒馆失败:', syncErr);
updateProgress(`✅ 第${cupNumber}杯已保存到收藏!(酒馆同步暂不可用)`);
}
// 步骤6: 插入标记,防止下次重复总结
insertSummaryMarker(cupNumber);
// 刷新收藏列表
refreshFavoritesList();
} catch (err) {
console.error('执行总结失败:', err);
updateProgress(`❌ 失败: ${err.message}`);
} finally {
// 恢复按钮
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = '执行总结';
}
}
}
// 回退总结(删除最后一杯)
async function rollbackSummary() {
const settings = extension_settings[extensionName];
const progressEl = document.getElementById('wechat-summary-progress');
const updateProgress = (msg) => {
if (progressEl) progressEl.textContent = msg;
};
// 查找【可乐】聊天记录世界书
const selectedLorebooks = settings.selectedLorebooks || [];
const lorebookIdx = selectedLorebooks.findIndex(lb => lb.name === LOREBOOK_NAME);
if (lorebookIdx < 0 || !selectedLorebooks[lorebookIdx].entries?.length) {
updateProgress('❌ 没有可回退的总结');
return;
}
const lorebook = selectedLorebooks[lorebookIdx];
const cupNumber = lorebook.entries.length; // 当前是第几杯
if (!confirm(`确定要回退第${cupNumber}杯总结吗?\n\n这将删除:\n1. 世界书中的第${cupNumber}杯条目\n2. 所有聊天记录中的"${SUMMARY_MARKER_PREFIX}${cupNumber}"标记`)) {
return;
}
updateProgress(`🔄 正在回退第${cupNumber}杯...`);
try {
// 1. 从收藏中删除最后一个条目
lorebook.entries.pop();
updateProgress('📋 已删除收藏中的条目...');
// 2. 从所有联系人聊天记录中删除对应标记
const markerToRemove = `${SUMMARY_MARKER_PREFIX}${cupNumber}`;
const contacts = settings.contacts || [];
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++;
}
}
});
updateProgress(`📋 已删除 ${removedCount} 个聊天标记...`);
// 3. 尝试从酒馆世界书中删除
try {
if (typeof loadWorldInfo === 'function' && typeof saveWorldInfo === 'function') {
const worldData = await loadWorldInfo(LOREBOOK_NAME);
if (worldData?.entries) {
// 删除对应的条目(key 是 cupNumber - 1)
const entryKey = cupNumber - 1;
if (worldData.entries[entryKey]) {
delete worldData.entries[entryKey];
await saveWorldInfo(LOREBOOK_NAME, worldData);
updateProgress('📤 已同步删除酒馆世界书条目...');
}
}
}
} catch (syncErr) {
console.error('同步删除酒馆条目失败:', syncErr);
// 不影响本地回退
}
// 4. 保存设置
saveSettingsDebounced();
// 5. 刷新界面
refreshFavoritesList();
refreshChatList();
// 如果当前在聊天页面,刷新聊天历史显示
if (currentChatIndex >= 0) {
const contact = settings.contacts[currentChatIndex];
if (contact) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer) {
messagesContainer.innerHTML = renderChatHistory(contact, contact.chatHistory || []);
bindVoiceBubbleEvents(messagesContainer);
}
}
}
updateProgress(`✅ 已回退第${cupNumber}杯,当前剩余 ${lorebook.entries.length} 杯`);
} catch (err) {
console.error('回退总结失败:', err);
updateProgress(`❌ 回退失败: ${err.message}`);
}
}
// 测试 API 连接
async function testApiConnection(apiUrl, apiKey) {
try {
// 尝试请求 /models 端点(OpenAI 兼容格式)
const modelsUrl = apiUrl.replace(/\/+$/, '') + '/models';
const headers = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(modelsUrl, {
method: 'GET',
headers: headers,
});
if (response.ok) {
const data = await response.json();
const modelCount = data.data?.length || 0;
return {
success: true,
message: `发现 ${modelCount} 个可用模型`
};
} else {
const errorText = await response.text();
return {
success: false,
message: `HTTP ${response.status}: ${errorText.substring(0, 100)}`
};
}
} catch (err) {
return {
success: false,
message: err.message
};
}
}
// 获取 API 配置
function getApiConfig() {
const settings = extension_settings[extensionName];
return {
url: settings.apiUrl || '',
key: settings.apiKey || '',
model: settings.selectedModel || 'gpt-3.5-turbo'
};
}
// 获取模型列表
async function fetchModelList() {
const apiUrl = document.getElementById('wechat-api-url')?.value.trim();
const apiKey = document.getElementById('wechat-api-key')?.value.trim();
if (!apiUrl) {
showToast('请先填写 API 地址', '⚠️');
return [];
}
const modelsUrl = apiUrl.replace(/\/+$/, '') + '/models';
const headers = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
try {
const response = await fetch(modelsUrl, {
method: 'GET',
headers: headers,
});
if (response.ok) {
const data = await response.json();
// 兼容 OpenAI 格式和其他格式
let models = [];
if (data.data && Array.isArray(data.data)) {
// OpenAI 格式
models = data.data.map(m => ({
id: m.id,
name: m.id
}));
} else if (Array.isArray(data)) {
// 直接数组格式
models = data.map(m => ({
id: typeof m === 'string' ? m : m.id,
name: typeof m === 'string' ? m : (m.name || m.id)
}));
}
return models;
} else {
const errorText = await response.text();
showToast(`获取模型列表失败: HTTP ${response.status}`, '❌');
return [];
}
} catch (err) {
showToast(`获取模型列表失败: ${err.message}`, '❌');
return [];
}
}
// 刷新模型下拉列表
async function refreshModelSelect() {
const select = document.getElementById('wechat-model-select');
const refreshBtn = document.getElementById('wechat-refresh-models');
if (!select) return;
// 显示加载状态
const originalText = refreshBtn?.textContent;
if (refreshBtn) {
refreshBtn.textContent = '加载中...';
refreshBtn.disabled = true;
}
const models = await fetchModelList();
const settings = extension_settings[extensionName];
// 清空现有选项
select.innerHTML = '';
if (models.length === 0) {
select.innerHTML = '';
} else {
select.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
if (model.id === settings.selectedModel) {
option.selected = true;
}
select.appendChild(option);
});
// 缓存模型列表
settings.modelList = models;
saveSettingsDebounced();
}
// 恢复按钮状态
if (refreshBtn) {
refreshBtn.textContent = originalText;
refreshBtn.disabled = false;
}
}
// 从缓存恢复模型列表
function restoreModelSelect() {
const select = document.getElementById('wechat-model-select');
if (!select) return;
const settings = extension_settings[extensionName];
const models = settings.modelList || [];
if (models.length > 0) {
select.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
if (model.id === settings.selectedModel) {
option.selected = true;
}
select.appendChild(option);
});
}
}
// 渲染聊天历史
function renderChatHistory(contact, chatHistory) {
const firstChar = contact.name ? contact.name.charAt(0) : '?';
const avatarContent = contact.avatar
? `
`
: firstChar;
let html = '';
let lastTimestamp = 0;
const TIME_GAP_THRESHOLD = 5 * 60 * 1000; // 5分钟间隔显示时间
chatHistory.forEach((msg, index) => {
// 获取消息时间戳
const msgTimestamp = msg.timestamp || new Date(msg.time).getTime() || 0;
// 检查是否是总结标记消息
if (msg.isMarker || msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
// 像时间戳一样居中显示标记
const markerText = msg.content || '可乐已加冰';
html += `${escapeHtml(markerText)}
`;
lastTimestamp = msgTimestamp;
return; // 跳过后续的普通消息渲染
}
// 判断是否需要显示时间标签(间隔超过5分钟或第一条消息)
if (index === 0 || (msgTimestamp - lastTimestamp > TIME_GAP_THRESHOLD)) {
const timeLabel = formatMessageTime(msgTimestamp);
if (timeLabel) {
html += `${timeLabel}
`;
}
}
lastTimestamp = msgTimestamp;
// 判断是否是语音消息
const isVoice = msg.isVoice === true;
let bubbleContent;
if (isVoice) {
bubbleContent = generateVoiceBubbleStatic(msg.content, msg.role === 'user');
} else {
bubbleContent = `${escapeHtml(msg.content)}
`;
}
if (msg.role === 'user') {
// 用户消息(右侧)
html += `
${getUserAvatarHTML()}
${bubbleContent}
`;
} else {
// AI/角色消息(左侧)
html += `
${avatarContent}
${bubbleContent}
`;
}
});
return html;
}
// 格式化消息时间标签(微信风格)
function formatMessageTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const oneDay = 24 * 60 * 60 * 1000;
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
// 今天:只显示时间
if (diff < oneDay && date.getDate() === now.getDate()) {
return timeStr;
}
// 昨天
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()) {
return `昨天 ${timeStr}`;
}
// 一周内:显示星期几
if (diff < 7 * oneDay) {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return `${days[date.getDay()]} ${timeStr}`;
}
// 更早:显示日期
return `${date.getMonth() + 1}月${date.getDate()}日 ${timeStr}`;
}
// 生成静态语音消息HTML(用于历史记录,带唯一ID)
function generateVoiceBubbleStatic(content, isSelf) {
const duration = calculateVoiceDuration(content);
const width = Math.min(200, Math.max(80, 60 + duration * 3));
const uniqueId = 'voice-hist-' + Math.random().toString(36).substring(2, 11);
// 语音图标SVG - 三条弧线样式(微信风格)
// 发送消息(右侧绿色气泡):弧线朝左 (((
// 接收消息(左侧白色气泡):弧线朝右 )))
const voiceIconSvg = isSelf
? ``
: ``;
return `
${duration}"
${voiceIconSvg}
${escapeHtml(content)}
`;
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 显示Toast提示
function showToast(message, icon = '✅') {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
// 移除已有的toast
const existingToast = phone.querySelector('.wechat-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = 'wechat-toast';
toast.innerHTML = `${icon}${escapeHtml(message)}`;
phone.appendChild(toast);
// 动画结束后移除
setTimeout(() => {
toast.remove();
}, 2000);
}
// 根据内容长度计算语音秒数
function calculateVoiceDuration(content) {
// 大约每3个字符1秒,最少2秒,最多60秒
const seconds = Math.max(2, Math.min(60, Math.ceil(content.length / 3)));
return seconds;
}
// 生成语音消息HTML
function generateVoiceBubble(content, isSelf) {
const duration = calculateVoiceDuration(content);
// 语音条宽度根据秒数变化,最小80px,最大200px
const width = Math.min(200, Math.max(80, 60 + duration * 3));
const uniqueId = 'voice-' + Date.now() + '-' + Math.random().toString(36).substring(2, 11);
// 语音图标SVG - 三条弧线样式(微信风格)
// 发送消息(右侧绿色气泡):弧线朝左 (((
// 接收消息(左侧白色气泡):弧线朝右 )))
const voiceIconSvg = isSelf
? ``
: ``;
return `
${duration}"
${voiceIconSvg}
${escapeHtml(content)}
`;
}
// 添加消息到聊天界面(支持语音消息)
function appendMessage(role, content, contact, isVoice = false) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
const avatarContent = contact?.avatar
? `
`
: firstChar;
let bubbleContent;
if (isVoice) {
bubbleContent = generateVoiceBubble(content, role === 'user');
} else {
bubbleContent = `${escapeHtml(content)}
`;
}
let messageHtml = '';
if (role === 'user') {
messageHtml = `
${getUserAvatarHTML()}
${bubbleContent}
`;
} else {
messageHtml = `
${avatarContent}
${bubbleContent}
`;
}
messagesContainer.insertAdjacentHTML('beforeend', messageHtml);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 绑定语音点击事件
if (isVoice) {
bindVoiceBubbleEvents(messagesContainer);
}
}
// 显示打字中状态
function showTypingIndicator(contact) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
const avatarContent = contact?.avatar
? `
`
: firstChar;
const typingHtml = `
`;
messagesContainer.insertAdjacentHTML('beforeend', typingHtml);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 隐藏打字中状态
function hideTypingIndicator() {
const indicator = document.querySelector('.wechat-typing-indicator');
if (indicator) {
indicator.remove();
}
}
// 从消息中提取指定标签内容(支持多个标签)
function extractCustomTags(message, tags) {
if (!tags || tags.length === 0) return '';
const results = [];
tags.forEach(tag => {
// 构建正则表达式,匹配 内容
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'gi');
const matches = message.match(regex);
if (matches) {
matches.forEach(m => {
const content = m.replace(new RegExp(`<\\/?${tag}>`, 'gi'), '').trim();
if (content) {
results.push(content);
}
});
}
});
return results.join('\n');
}
// 从主界面消息中提取时间
function extractTimeFromSTChat() {
const settings = extension_settings[extensionName];
try {
const context = getContext();
const chat = context.chat || [];
if (chat.length === 0) return null;
// 从最近的消息中查找时间标签(取最近5条)
const recentChat = chat.slice(-5);
// 时间标签列表(优先级从高到低)
const defaultTimeTags = ['time', 'timestamp', '时间', 'datetime', 'date', 'now'];
// 合并用户配置的标签中可能包含时间的标签
const customTags = settings.contextTags || [];
const timeRelatedCustomTags = customTags.filter(tag =>
tag.toLowerCase().includes('time') ||
tag.includes('时间') ||
tag.includes('日期')
);
const allTimeTags = [...defaultTimeTags, ...timeRelatedCustomTags];
// 从最新消息向前搜索
for (let i = recentChat.length - 1; i >= 0; i--) {
const msg = recentChat[i];
const content = msg.mes || '';
// 尝试从标签中提取时间
for (const tag of allTimeTags) {
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i');
const match = content.match(regex);
if (match && match[1]) {
const timeStr = match[1].trim();
const parsedTime = parseTimeString(timeStr);
if (parsedTime) {
console.log(`[可乐不加冰] 从主界面提取到时间: ${timeStr} -> ${new Date(parsedTime).toLocaleString()}`);
return parsedTime;
}
}
}
}
return null;
} catch (err) {
console.error('提取时间失败:', err);
return null;
}
}
// 解析时间字符串为时间戳
function parseTimeString(timeStr) {
if (!timeStr) return null;
// 格式1: HH:MM 或 H:MM(纯时间,使用今天日期)
const timeOnlyMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
if (timeOnlyMatch) {
const now = new Date();
const hours = parseInt(timeOnlyMatch[1]);
const minutes = parseInt(timeOnlyMatch[2]);
if (hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60) {
now.setHours(hours, minutes, 0, 0);
return now.getTime();
}
}
// 格式2: YYYY-MM-DD HH:MM:SS 或 YYYY/MM/DD HH:MM:SS
const fullDateMatch = timeStr.match(/(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
if (fullDateMatch) {
const date = new Date(
parseInt(fullDateMatch[1]),
parseInt(fullDateMatch[2]) - 1,
parseInt(fullDateMatch[3]),
parseInt(fullDateMatch[4]),
parseInt(fullDateMatch[5]),
parseInt(fullDateMatch[6] || '0')
);
return date.getTime();
}
// 格式3: MM-DD HH:MM 或 M月D日 HH:MM(使用今年)
const dateTimeMatch = timeStr.match(/(\d{1,2})[-月](\d{1,2})[日]?\s+(\d{1,2}):(\d{2})/);
if (dateTimeMatch) {
const now = new Date();
const date = new Date(
now.getFullYear(),
parseInt(dateTimeMatch[1]) - 1,
parseInt(dateTimeMatch[2]),
parseInt(dateTimeMatch[3]),
parseInt(dateTimeMatch[4])
);
return date.getTime();
}
// 格式4: 中文描述如"上午10:30"、"下午3:45"、"凌晨2:00"
const chineseTimeMatch = timeStr.match(/(上午|下午|凌晨|中午|晚上|早上)?(\d{1,2}):(\d{2})/);
if (chineseTimeMatch) {
const now = new Date();
let hours = parseInt(chineseTimeMatch[2]);
const minutes = parseInt(chineseTimeMatch[3]);
const period = chineseTimeMatch[1];
if (period === '下午' || period === '晚上') {
if (hours < 12) hours += 12;
} else if ((period === '上午' || period === '凌晨' || period === '早上') && hours === 12) {
hours = 0;
}
now.setHours(hours, minutes, 0, 0);
return now.getTime();
}
// 格式5: 纯数字时间戳
if (/^\d{10,13}$/.test(timeStr)) {
const ts = parseInt(timeStr);
// 如果是10位(秒),转换为毫秒
return ts < 10000000000 ? ts * 1000 : ts;
}
// 格式6: 尝试 Date.parse(最后手段)
const parsed = Date.parse(timeStr);
if (!isNaN(parsed)) {
return parsed;
}
return null;
}
// 获取酒馆主聊天的上下文
function getSTChatContext(layers) {
const settings = extension_settings[extensionName];
// 检查开关
if (!settings.contextEnabled) return '';
if (layers <= 0) return '';
const tags = settings.contextTags || [];
if (tags.length === 0) return '';
try {
const context = getContext();
const chat = context.chat || [];
if (chat.length === 0) return '';
// 取最近 N 条消息
const recentChat = chat.slice(-layers);
// 提取标签内容
const contents = [];
recentChat.forEach(msg => {
const extracted = extractCustomTags(msg.mes || '', tags);
if (extracted) {
const role = msg.is_user ? '用户' : (msg.name || '角色');
contents.push(`[${role}]: ${extracted}`);
}
});
if (contents.length === 0) return '';
return `【剧情上下文】\n${contents.join('\n')}\n`;
} catch (err) {
console.error('获取酒馆上下文失败:', err);
return '';
}
}
// 刷新上下文标签显示
function refreshContextTags() {
const settings = extension_settings[extensionName];
const tagsContainer = document.getElementById('wechat-context-tags');
if (!tagsContainer) return;
const tags = settings.contextTags || [];
// 标签 + 添加按钮,按钮始终在最后
tagsContainer.innerHTML = tags.map((tag, i) => `
<${tag}>
`).join('') + '';
}
// 构建 AI 请求的系统提示
function buildSystemPrompt(contact) {
const settings = extension_settings[extensionName];
const rawData = contact.rawData || {};
const charData = rawData.data || rawData;
let systemPrompt = '';
// 酒馆主聊天上下文(根据层数设置)
const contextLevel = settings.contextLevel ?? 5;
const stContext = getSTChatContext(contextLevel);
if (stContext) {
systemPrompt += stContext + '\n';
}
// 用户设定(收集所有启用的设定)
const userPersonas = settings.userPersonas || [];
const enabledPersonas = userPersonas.filter(p => p.enabled !== false);
if (enabledPersonas.length > 0) {
systemPrompt += `【用户设定】\n`;
enabledPersonas.forEach(persona => {
if (persona.name) {
systemPrompt += `[${persona.name}]\n`;
}
if (persona.content) {
systemPrompt += `${persona.content}\n`;
}
});
systemPrompt += '\n';
}
// 角色名
if (charData.name) {
systemPrompt += `你是 ${charData.name}。\n\n`;
}
// 角色描述
if (charData.description) {
systemPrompt += `【角色描述】\n${charData.description}\n\n`;
}
// 角色性格
if (charData.personality) {
systemPrompt += `【性格】\n${charData.personality}\n\n`;
}
// 场景
if (charData.scenario) {
systemPrompt += `【场景】\n${charData.scenario}\n\n`;
}
// 示例对话
if (charData.mes_example) {
systemPrompt += `【示例对话】\n${charData.mes_example}\n\n`;
}
// 世界书/角色书条目 - 只包含启用的条目
if (charData.character_book?.entries?.length > 0) {
const enabledEntries = charData.character_book.entries.filter(entry =>
entry.enabled !== false && entry.disable !== true
);
if (enabledEntries.length > 0) {
systemPrompt += `【世界观设定】\n`;
enabledEntries.forEach(entry => {
if (entry.content) {
systemPrompt += `- ${entry.content}\n`;
}
});
systemPrompt += '\n';
}
}
// 选择的世界书条目 - 只包含启用的
const selectedLorebooks = settings.selectedLorebooks || [];
const enabledLorebookEntries = [];
selectedLorebooks.forEach(lb => {
if (lb.enabled === false) return; // 整本世界书禁用
(lb.entries || []).forEach(entry => {
if (entry.enabled !== false && entry.disable !== true && entry.content) {
enabledLorebookEntries.push(entry.content);
}
});
});
if (enabledLorebookEntries.length > 0) {
systemPrompt += `【世界书设定】\n`;
enabledLorebookEntries.forEach(content => {
systemPrompt += `- ${content}\n`;
});
systemPrompt += '\n';
}
// 添加微信对话格式提示
systemPrompt += `【回复格式】
你正在通过微信与用户聊天。请用简短、自然的口语化方式回复,就像真实的微信聊天一样。
- 你可以发送多条消息,每条消息之间用 ||| 分隔
- 每条消息不要太长,控制在1-2句话
- 可以使用表情符号
- 回复要符合角色性格
- 不要使用任何格式标记,直接输出对话内容
- 如果想发送语音消息,使用格式:[语音:语音内容]
示例(多条消息):
你在干嘛|||想你了|||今天工作好累啊
示例(包含语音):
[语音:宝贝我想你了,今天怎么没给我发消息啊]|||你是不是把我忘了`;
return systemPrompt;
}
// 构建消息历史
function buildMessages(contact, userMessage) {
const systemPrompt = buildSystemPrompt(contact);
const chatHistory = contact.chatHistory || [];
const messages = [
{ role: 'system', content: systemPrompt }
];
// 添加历史消息(最多保留300条)
// 注意:调用此函数时,当前用户消息还未加入 chatHistory,所以不会重复
const recentHistory = chatHistory.slice(-300);
recentHistory.forEach(msg => {
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
});
});
// 添加当前用户的最新消息
messages.push({ role: 'user', content: userMessage });
return messages;
}
// 调用 AI API
async function callAI(contact, userMessage) {
const apiConfig = getApiConfig();
if (!apiConfig.url) {
throw new Error('请先在设置中配置 API 地址');
}
if (!apiConfig.model) {
throw new Error('请先在设置中选择模型');
}
const messages = buildMessages(contact, userMessage);
const chatUrl = apiConfig.url.replace(/\/+$/, '') + '/chat/completions';
const headers = {
'Content-Type': 'application/json',
};
if (apiConfig.key) {
headers['Authorization'] = `Bearer ${apiConfig.key}`;
}
const response = await fetch(chatUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify({
model: apiConfig.model,
messages: messages,
temperature: 1,
max_tokens: 8196
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API 错误 (${response.status}): ${errorText.substring(0, 100)}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '...';
}
// 发送消息(支持多条消息数组和语音消息)
async function sendMessage(messageText, isMultipleMessages = false, isVoice = false) {
if (currentChatIndex < 0) return;
const settings = extension_settings[extensionName];
const contact = settings.contacts[currentChatIndex];
if (!contact) return;
// 初始化聊天历史
if (!contact.chatHistory) {
contact.chatHistory = [];
}
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')}`;
// 处理消息列表(支持多条消息)
let messagesToSend = [];
if (isMultipleMessages && Array.isArray(messageText)) {
messagesToSend = messageText.filter(m => m.trim());
} else if (typeof messageText === 'string' && messageText.trim()) {
messagesToSend = [messageText.trim()];
}
if (messagesToSend.length === 0) return;
// 清空输入框
const input = document.getElementById('wechat-input');
if (input) input.value = '';
// 从主界面提取时间,如果没有则使用系统时间
const extractedTime = extractTimeFromSTChat();
const msgTimestamp = extractedTime || Date.now();
// 先在界面上显示用户消息(但暂不加入历史)
for (let i = 0; i < messagesToSend.length; i++) {
const msg = messagesToSend[i];
appendMessage('user', msg, contact, isVoice);
if (i < messagesToSend.length - 1) {
await sleep(300);
}
}
// 更新最后一条消息预览
contact.lastMessage = isVoice ? '[语音消息]' : messagesToSend[messagesToSend.length - 1];
// 显示打字中状态
showTypingIndicator(contact);
try {
// 调用 AI - 此时 chatHistory 还不包含当前用户消息,所以不会重复
const combinedMessage = isVoice
? `[用户发送了语音消息,内容是:${messagesToSend.join('\n')}]`
: messagesToSend.join('\n');
const aiResponse = await callAI(contact, combinedMessage);
// 隐藏打字中状态
hideTypingIndicator();
// AI 调用成功后,才把用户消息加入历史(使用提取的时间或系统时间)
for (const msg of messagesToSend) {
contact.chatHistory.push({
role: 'user',
content: msg,
time: timeStr,
timestamp: msgTimestamp,
isVoice: isVoice
});
}
// 解析 AI 回复(支持多条消息,用 ||| 分隔,支持语音格式 [语音:内容])
const aiMessages = aiResponse.split('|||').map(m => m.trim()).filter(m => m);
// 依次显示 AI 的多条回复
for (let i = 0; i < aiMessages.length; i++) {
let aiMsg = aiMessages[i];
let aiIsVoice = false;
// 检查是否是语音消息格式 [语音:内容] 或 [语音:内容]
const voiceMatch = aiMsg.match(/^\[语音[::]\s*(.+?)\]$/);
if (voiceMatch) {
aiMsg = voiceMatch[1];
aiIsVoice = true;
}
// 添加 AI 回复到历史
contact.chatHistory.push({
role: 'assistant',
content: aiMsg,
time: timeStr,
timestamp: Date.now(),
isVoice: aiIsVoice
});
// 显示 AI 回复
appendMessage('assistant', aiMsg, contact, aiIsVoice);
// 如果不是最后一条,显示打字中并添加延迟
if (i < aiMessages.length - 1) {
showTypingIndicator(contact);
await sleep(800 + Math.random() * 400); // 随机延迟 800-1200ms
hideTypingIndicator();
}
}
// 更新最后一条消息预览
const lastAiMsg = aiMessages[aiMessages.length - 1];
const lastVoiceMatch = lastAiMsg.match(/^\[语音[::]\s*(.+?)\]$/);
contact.lastMessage = lastVoiceMatch ? '[语音消息]' : lastAiMsg;
saveSettingsDebounced();
refreshChatList(); // 刷新聊天列表显示最新消息
} catch (err) {
hideTypingIndicator();
console.error('AI 调用失败:', err);
// 即使失败,也要把用户消息加入历史(使用提取的时间或系统时间)
for (const msg of messagesToSend) {
contact.chatHistory.push({
role: 'user',
content: msg,
time: timeStr,
timestamp: msgTimestamp,
isVoice: isVoice
});
}
saveSettingsDebounced();
refreshChatList(); // 刷新聊天列表
// 显示错误消息
appendMessage('assistant', `⚠️ ${err.message}`, contact);
}
}
// 睡眠函数
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 注入作者注释
function injectAuthorNote() {
try {
const context = getContext();
if (context && context.setExtensionPrompt) {
context.setExtensionPrompt(extensionName, authorNoteTemplate, 1, 0);
showToast('微信格式提示已注入');
} else {
// 备用方案:尝试直接修改
const authorNoteTextarea = document.querySelector('#author_note_text');
if (authorNoteTextarea) {
authorNoteTextarea.value = authorNoteTemplate;
authorNoteTextarea.dispatchEvent(new Event('input'));
showToast('微信格式提示已注入');
} else {
showToast('无法找到作者注释区域', '⚠️');
console.log('作者注释模板:', authorNoteTemplate);
}
}
} catch (err) {
console.error('注入作者注释失败:', err);
showToast('注入失败,请手动添加', '❌');
}
}
let phoneAutoCenteringBound = false;
let phoneManuallyPositioned = false; // 用户是否手动拖拽过
function centerPhoneInViewport({ force = false } = {}) {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
if (!force && phone.classList.contains('hidden')) return;
// 如果用户手动拖拽过,不自动居中(除非是首次显示)
const settings = extension_settings[extensionName];
if (phoneManuallyPositioned && settings.phonePosition && !force) {
return;
}
// 如果有保存的位置,使用保存的位置
if (settings.phonePosition && !force) {
phone.style.setProperty('left', `${settings.phonePosition.x}px`, 'important');
phone.style.setProperty('top', `${settings.phonePosition.y}px`, 'important');
phoneManuallyPositioned = true;
return;
}
const viewport = window.visualViewport;
const rawViewportWidth = viewport?.width ?? window.innerWidth;
const rawViewportHeight = viewport?.height ?? window.innerHeight;
const viewportWidth = rawViewportWidth >= 100 ? rawViewportWidth : window.innerWidth;
const viewportHeight = rawViewportHeight >= 100 ? rawViewportHeight : window.innerHeight;
const viewportLeft = viewport?.offsetLeft ?? 0;
const viewportTop = viewport?.offsetTop ?? 0;
const isCoarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches ?? false;
const maxWidth = isCoarsePointer ? 360 : 375;
const maxHeight = isCoarsePointer ? 700 : 667;
const margin = isCoarsePointer ? 8 : 12;
const availableWidth = Math.max(0, Math.floor(viewportWidth - margin * 2));
const availableHeight = Math.max(0, Math.floor(viewportHeight - margin * 2));
const targetWidth = Math.min(maxWidth, availableWidth);
const targetHeight = Math.min(maxHeight, availableHeight);
if (targetWidth > 0) phone.style.setProperty('width', `${targetWidth}px`, 'important');
if (targetHeight > 0) phone.style.setProperty('height', `${targetHeight}px`, 'important');
phone.style.setProperty('max-width', 'none', 'important');
phone.style.setProperty('max-height', 'none', 'important');
const effectiveWidth = targetWidth > 0 ? targetWidth : phone.getBoundingClientRect().width;
const effectiveHeight = targetHeight > 0 ? targetHeight : phone.getBoundingClientRect().height;
const unclampedCenterX = viewportLeft + viewportWidth / 2;
const unclampedCenterY = viewportTop + viewportHeight / 2;
const minCenterX = viewportLeft + margin + effectiveWidth / 2;
const maxCenterX = viewportLeft + viewportWidth - margin - effectiveWidth / 2;
const minCenterY = viewportTop + margin + effectiveHeight / 2;
const maxCenterY = viewportTop + viewportHeight - margin - effectiveHeight / 2;
const centerX = Math.round(Math.min(Math.max(unclampedCenterX, minCenterX), maxCenterX));
const centerY = Math.round(Math.min(Math.max(unclampedCenterY, minCenterY), maxCenterY));
phone.style.setProperty('left', `${centerX}px`, 'important');
phone.style.setProperty('top', `${centerY}px`, 'important');
phone.style.setProperty('right', 'auto', 'important');
phone.style.setProperty('bottom', 'auto', 'important');
}
// 设置手机拖拽功能
function setupPhoneDrag() {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
let isDragging = false;
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
// 拖拽手柄:状态栏区域
const statusbar = phone.querySelector('.wechat-statusbar');
if (!statusbar) return;
// 添加拖拽提示样式
statusbar.style.cursor = 'grab';
statusbar.title = '拖拽移动手机位置';
const handleStart = (e) => {
// 排除按钮点击
if (e.target.closest('button') || e.target.closest('a')) return;
isDragging = true;
statusbar.style.cursor = 'grabbing';
const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
startX = clientX;
startY = clientY;
const rect = phone.getBoundingClientRect();
initialX = rect.left + rect.width / 2;
initialY = rect.top + rect.height / 2;
e.preventDefault();
};
const handleMove = (e) => {
if (!isDragging) return;
const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaX = clientX - startX;
const deltaY = clientY - startY;
const newX = initialX + deltaX;
const newY = initialY + deltaY;
phone.style.setProperty('left', `${newX}px`, 'important');
phone.style.setProperty('top', `${newY}px`, 'important');
e.preventDefault();
};
const handleEnd = () => {
if (!isDragging) return;
isDragging = false;
statusbar.style.cursor = 'grab';
phoneManuallyPositioned = true;
// 保存位置到设置
const rect = phone.getBoundingClientRect();
const settings = extension_settings[extensionName];
settings.phonePosition = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
saveSettingsDebounced();
};
// 鼠标事件
statusbar.addEventListener('mousedown', handleStart);
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
// 触摸事件
statusbar.addEventListener('touchstart', handleStart, { passive: false });
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('touchend', handleEnd);
// 双击状态栏重置位置到中心
statusbar.addEventListener('dblclick', () => {
phoneManuallyPositioned = false;
const settings = extension_settings[extensionName];
delete settings.phonePosition;
saveSettingsDebounced();
centerPhoneInViewport({ force: true });
});
}
function setupPhoneAutoCentering() {
if (phoneAutoCenteringBound) return;
phoneAutoCenteringBound = true;
let rafPending = false;
const handler = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
centerPhoneInViewport();
});
};
window.addEventListener('resize', handler);
window.addEventListener('orientationchange', handler);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handler);
window.visualViewport.addEventListener('scroll', handler);
}
const phone = document.getElementById('wechat-phone');
phone?.addEventListener('focusin', () => {
centerPhoneInViewport({ force: true });
setTimeout(() => centerPhoneInViewport({ force: true }), 250);
if (document.activeElement?.id === 'wechat-input') {
const messages = document.getElementById('wechat-chat-messages');
if (messages) messages.scrollTop = messages.scrollHeight;
}
});
phone?.addEventListener('focusout', () => {
setTimeout(() => centerPhoneInViewport({ force: true }), 250);
});
setTimeout(() => centerPhoneInViewport({ force: true }), 0);
}
// 切换手机显示
function togglePhone() {
const phone = document.getElementById('wechat-phone');
const settings = extension_settings[extensionName];
phone.classList.toggle('hidden');
settings.phoneVisible = !phone.classList.contains('hidden');
saveSettingsDebounced();
// 更新时间
if (settings.phoneVisible) {
document.querySelector('.wechat-statusbar-time').textContent = getCurrentTime();
centerPhoneInViewport();
setTimeout(() => centerPhoneInViewport({ force: true }), 150);
}
}
// 切换深色模式
function toggleDarkMode() {
const phone = document.getElementById('wechat-phone');
const toggle = document.getElementById('wechat-dark-toggle');
const settings = extension_settings[extensionName];
settings.darkMode = !settings.darkMode;
phone.classList.toggle('wechat-dark', settings.darkMode);
toggle.classList.toggle('on', settings.darkMode);
saveSettingsDebounced();
}
// 解析聊天消息中的微信格式
function parseWeChatMessage(text) {
const patterns = [
{ regex: /\[微信:\s*(.+?)\]/g, type: 'text' },
{ regex: /\[语音:\s*(\d+)秒?\]/g, type: 'voice' },
{ regex: /\[图片:\s*(.+?)\]/g, type: 'image' },
{ regex: /\[表情:\s*(.+?)\]/g, type: 'emoji' },
{ regex: /\[红包:\s*(.+?)\]/g, type: 'redpacket' },
{ regex: /\[转账:\s*(.+?)\]/g, type: 'transfer' },
{ regex: /\[撤回\]/g, type: 'recall' },
];
const messages = [];
let lastIndex = 0;
let match;
// 合并所有匹配
const allMatches = [];
for (const pattern of patterns) {
pattern.regex.lastIndex = 0;
while ((match = pattern.regex.exec(text)) !== null) {
allMatches.push({
index: match.index,
length: match[0].length,
type: pattern.type,
content: match[1] || ''
});
}
}
// 按位置排序
allMatches.sort((a, b) => a.index - b.index);
return allMatches;
}
// 展开面板相关
let expandMode = null; // 'voice' 或 'multi'
let expandMsgItems = [''];
// 显示展开面板 - 语音模式
function showExpandVoice() {
expandMode = 'voice';
const panel = document.getElementById('wechat-expand-input');
const title = document.getElementById('wechat-expand-title');
const body = document.getElementById('wechat-expand-body');
title.textContent = '语音消息';
body.innerHTML = `
输入语音内容,系统会根据字数计算时长
预计时长:
0"
`;
panel.classList.remove('hidden');
// 绑定输入事件更新时长
const textarea = document.getElementById('wechat-expand-voice-text');
textarea.addEventListener('input', updateExpandVoiceDuration);
setTimeout(() => textarea.focus(), 50);
}
// 更新语音时长预览
function updateExpandVoiceDuration() {
const textarea = document.getElementById('wechat-expand-voice-text');
const durationEl = document.getElementById('wechat-expand-voice-duration');
if (textarea && durationEl) {
const content = textarea.value.trim();
const duration = content ? calculateVoiceDuration(content) : 0;
durationEl.textContent = duration + '"';
}
}
// 显示展开面板 - 多条消息模式
function showExpandMulti() {
expandMode = 'multi';
expandMsgItems = [''];
const panel = document.getElementById('wechat-expand-input');
const title = document.getElementById('wechat-expand-title');
title.textContent = '多条消息';
renderExpandMsgList();
panel.classList.remove('hidden');
// 聚焦第一个输入框
setTimeout(() => {
const firstInput = document.querySelector('.wechat-expand-msg-input');
if (firstInput) firstInput.focus();
}, 50);
}
// 渲染多条消息列表
function renderExpandMsgList() {
const body = document.getElementById('wechat-expand-body');
let html = '';
expandMsgItems.forEach((msg, index) => {
html += `
${index + 1}
${expandMsgItems.length > 1 ? `` : ''}
`;
});
html += '
';
html += '';
body.innerHTML = html;
// 绑定事件
document.querySelectorAll('.wechat-expand-msg-input').forEach(input => {
input.addEventListener('input', (e) => {
expandMsgItems[parseInt(e.target.dataset.index)] = e.target.value;
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addExpandMsgItem();
}
});
});
document.querySelectorAll('.wechat-expand-msg-del').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(e.target.dataset.index);
expandMsgItems.splice(index, 1);
renderExpandMsgList();
});
});
document.getElementById('wechat-expand-add-msg')?.addEventListener('click', addExpandMsgItem);
}
// 添加一条消息
function addExpandMsgItem() {
expandMsgItems.push('');
renderExpandMsgList();
// 聚焦新输入框
setTimeout(() => {
const inputs = document.querySelectorAll('.wechat-expand-msg-input');
const lastInput = inputs[inputs.length - 1];
if (lastInput) lastInput.focus();
}, 50);
}
// 关闭展开面板
function closeExpandPanel() {
const panel = document.getElementById('wechat-expand-input');
panel.classList.add('hidden');
expandMode = null;
}
// 功能面板相关
let funcPanelPage = 0;
function toggleFuncPanel() {
const panel = document.getElementById('wechat-func-panel');
const expandPanel = document.getElementById('wechat-expand-input');
// 如果语音/多条消息面板打开,先关闭它
if (!expandPanel.classList.contains('hidden')) {
expandPanel.classList.add('hidden');
expandMode = null;
}
panel.classList.toggle('hidden');
}
function hideFuncPanel() {
const panel = document.getElementById('wechat-func-panel');
panel.classList.add('hidden');
}
function showFuncPanel() {
const panel = document.getElementById('wechat-func-panel');
panel.classList.remove('hidden');
}
function setFuncPanelPage(pageIndex) {
funcPanelPage = pageIndex;
const pages = document.getElementById('wechat-func-pages');
const dots = document.querySelectorAll('.wechat-func-dot');
if (pages) {
pages.style.transform = `translateX(-${pageIndex * 100}%)`;
}
dots.forEach((dot, idx) => {
dot.classList.toggle('active', idx === pageIndex);
});
}
function initFuncPanel() {
const pages = document.getElementById('wechat-func-pages');
if (!pages) return;
let startX = 0;
let currentX = 0;
let isDragging = false;
// 开始拖拽(触摸/鼠标)
const handleStart = (e) => {
startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
currentX = startX;
isDragging = true;
pages.style.transition = 'none';
};
// 拖拽中(触摸/鼠标)
const handleMove = (e) => {
if (!isDragging) return;
currentX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
};
// 结束拖拽(触摸/鼠标)
const handleEnd = () => {
if (!isDragging) return;
isDragging = false;
pages.style.transition = 'transform 0.3s ease';
const diff = startX - currentX;
if (Math.abs(diff) > 50) {
if (diff > 0 && funcPanelPage < 1) {
setFuncPanelPage(1);
} else if (diff < 0 && funcPanelPage > 0) {
setFuncPanelPage(0);
}
}
};
// 触摸事件
pages.addEventListener('touchstart', handleStart, { passive: true });
pages.addEventListener('touchmove', handleMove, { passive: true });
pages.addEventListener('touchend', handleEnd);
// 鼠标事件(电脑端支持)
pages.addEventListener('mousedown', (e) => {
handleStart(e);
e.preventDefault();
});
pages.addEventListener('mousemove', handleMove);
pages.addEventListener('mouseup', handleEnd);
pages.addEventListener('mouseleave', handleEnd);
// 点击指示点切换页面
document.querySelectorAll('.wechat-func-dot').forEach(dot => {
dot.addEventListener('click', () => {
const page = parseInt(dot.dataset.page);
setFuncPanelPage(page);
});
});
// 功能项点击
document.querySelectorAll('.wechat-func-item').forEach(item => {
item.addEventListener('click', () => {
const func = item.dataset.func;
handleFuncItemClick(func);
});
});
}
function handleFuncItemClick(func) {
switch (func) {
case 'voice':
hideFuncPanel();
showExpandVoice();
break;
case 'multi':
hideFuncPanel();
showExpandMulti();
break;
case 'photo':
case 'camera':
case 'videocall':
case 'location':
case 'redpacket':
case 'gift':
case 'transfer':
case 'favorites':
case 'contact':
case 'file':
case 'card':
case 'music':
// 暂时只提示功能开发中
showToast('该功能开发中...', '🚧');
break;
}
}
// 发送展开面板的内容
function sendExpandContent() {
if (expandMode === 'voice') {
const textarea = document.getElementById('wechat-expand-voice-text');
const content = textarea?.value.trim();
if (!content) {
showToast('请输入语音内容', '⚠️');
return;
}
closeExpandPanel();
sendMessage(content, false, true);
} else if (expandMode === 'multi') {
const validMessages = expandMsgItems.filter(m => m.trim());
if (validMessages.length === 0) {
showToast('请至少输入一条消息', '⚠️');
return;
}
closeExpandPanel();
sendMessage(validMessages, true);
}
}
// 绑定事件
function bindEvents() {
// 添加按钮 - 显示下拉菜单
document.getElementById('wechat-add-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = document.getElementById('wechat-dropdown-menu');
dropdown.classList.toggle('hidden');
});
// 点击其他地方关闭下拉菜单
document.getElementById('wechat-phone')?.addEventListener('click', (e) => {
if (!e.target.closest('#wechat-add-btn') && !e.target.closest('#wechat-dropdown-menu')) {
document.getElementById('wechat-dropdown-menu')?.classList.add('hidden');
}
});
// 通讯录页面的添加按钮 - 直接进入添加朋友页面
document.getElementById('wechat-contacts-add-btn')?.addEventListener('click', () => {
showPage('wechat-add-page');
});
// 下拉菜单 - 添加朋友
document.getElementById('wechat-menu-add-friend')?.addEventListener('click', () => {
document.getElementById('wechat-dropdown-menu').classList.add('hidden');
showPage('wechat-add-page');
});
// 下拉菜单 - 其他选项(暂时只关闭菜单)
document.getElementById('wechat-menu-group')?.addEventListener('click', () => {
document.getElementById('wechat-dropdown-menu').classList.add('hidden');
});
document.getElementById('wechat-menu-scan')?.addEventListener('click', () => {
document.getElementById('wechat-dropdown-menu').classList.add('hidden');
});
document.getElementById('wechat-menu-pay')?.addEventListener('click', () => {
document.getElementById('wechat-dropdown-menu').classList.add('hidden');
});
// 返回按钮
document.getElementById('wechat-back-btn')?.addEventListener('click', () => {
showPage('wechat-main-content');
});
document.getElementById('wechat-chat-back-btn')?.addEventListener('click', () => {
currentChatIndex = -1;
showPage('wechat-main-content');
refreshContactsList(); // 刷新列表显示最新消息
});
document.getElementById('wechat-settings-back-btn')?.addEventListener('click', () => {
showPage('wechat-me-page');
});
document.getElementById('wechat-favorites-back-btn')?.addEventListener('click', () => {
showPage('wechat-me-page');
});
// 导入 PNG
document.getElementById('wechat-import-png')?.addEventListener('click', () => {
document.getElementById('wechat-file-png').click();
});
// 导入 JSON
document.getElementById('wechat-import-json')?.addEventListener('click', () => {
document.getElementById('wechat-file-json').click();
});
// PNG 文件选择
document.getElementById('wechat-file-png')?.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
try {
const charData = await extractCharacterFromPNG(file);
charData.file = file;
// 直接添加联系人,不显示确认弹窗
if (addContact(charData)) {
showToast('导入成功', '✅');
// 尝试导入到 SillyTavern(静默失败)
try {
await importCharacterToST(charData);
} catch (err) {
console.log('导入到酒馆失败(可忽略):', err.message);
}
showPage('wechat-main-content');
}
} catch (err) {
showToast(err.message, '❌');
}
this.value = '';
});
// JSON 文件选择
document.getElementById('wechat-file-json')?.addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
try {
const charData = await extractCharacterFromJSON(file);
charData.file = file;
// 直接添加联系人,不显示确认弹窗
if (addContact(charData)) {
showToast('导入成功', '✅');
// 尝试导入到 SillyTavern(静默失败)
try {
await importCharacterToST(charData);
} catch (err) {
console.log('导入到酒馆失败(可忽略):', err.message);
}
showPage('wechat-main-content');
}
} catch (err) {
showToast(err.message, '❌');
}
this.value = '';
});
// 深色模式切换
document.getElementById('wechat-dark-toggle')?.addEventListener('click', toggleDarkMode);
// 聊天输入框发送消息
const chatInput = document.getElementById('wechat-input');
if (chatInput) {
// 按回车发送
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(chatInput.value);
}
});
}
// 点击 + 按钮切换功能面板
document.querySelector('.wechat-chat-input-more')?.addEventListener('click', () => {
toggleFuncPanel();
});
// 语音按钮 - 快捷方式直接打开语音输入
document.querySelector('.wechat-chat-input-voice')?.addEventListener('click', () => {
hideFuncPanel();
showExpandVoice();
});
// 功能面板滑动和点击
initFuncPanel();
// 展开面板 - 关闭按钮
document.getElementById('wechat-expand-close')?.addEventListener('click', () => {
closeExpandPanel();
});
// 展开面板 - 发送按钮
document.getElementById('wechat-expand-send')?.addEventListener('click', () => {
sendExpandContent();
});
// 标签栏切换(处理所有标签栏,包括主页面和"我"页面)
document.querySelectorAll('.wechat-tab').forEach(tab => {
tab.addEventListener('click', function() {
// 更新所有标签栏的状态
document.querySelectorAll('.wechat-tab').forEach(t => {
if (t.dataset.tab === this.dataset.tab) {
t.classList.add('active');
} else {
t.classList.remove('active');
}
});
const tabName = this.dataset.tab;
if (tabName === 'me') {
showPage('wechat-me-page');
} else if (tabName === 'chat') {
showPage('wechat-main-content');
// 显示微信聊天列表,隐藏通讯录
document.getElementById('wechat-chat-tab-content')?.classList.remove('hidden');
document.getElementById('wechat-contacts-tab-content')?.classList.add('hidden');
// 刷新聊天列表
refreshChatList();
} else if (tabName === 'contacts') {
showPage('wechat-main-content');
// 显示通讯录,隐藏微信聊天列表
document.getElementById('wechat-chat-tab-content')?.classList.add('hidden');
document.getElementById('wechat-contacts-tab-content')?.classList.remove('hidden');
} else {
// 其他标签暂时也显示主页面
showPage('wechat-main-content');
}
});
});
// 聊天列表项点击 - 进入聊天
document.getElementById('wechat-chat-list')?.addEventListener('click', (e) => {
const chatItem = e.target.closest('.wechat-chat-item');
if (chatItem) {
const contactId = chatItem.dataset.contactId;
const index = parseInt(chatItem.dataset.index);
if (contactId) {
openChatByContactId(contactId, index);
}
}
});
// "我"页面菜单
document.getElementById('wechat-menu-favorites')?.addEventListener('click', () => {
showPage('wechat-favorites-page');
});
document.getElementById('wechat-menu-settings')?.addEventListener('click', () => {
showPage('wechat-settings-page');
});
// 服务页面
document.getElementById('wechat-menu-service')?.addEventListener('click', () => {
showPage('wechat-service-page');
});
document.getElementById('wechat-service-back-btn')?.addEventListener('click', () => {
showPage('wechat-me-page');
});
// 服务页面 - 钱包点击切换滑出面板
document.getElementById('wechat-service-wallet')?.addEventListener('click', () => {
const walletPanel = document.getElementById('wechat-wallet-panel');
const contextPanel = document.getElementById('wechat-context-panel');
// 关闭另一个面板
contextPanel?.classList.add('hidden');
// 切换当前面板
walletPanel?.classList.toggle('hidden');
});
// 服务页面 - 上下文设置点击切换滑出面板
document.getElementById('wechat-service-context')?.addEventListener('click', () => {
const contextPanel = document.getElementById('wechat-context-panel');
const walletPanel = document.getElementById('wechat-wallet-panel');
// 关闭另一个面板
walletPanel?.classList.add('hidden');
// 切换当前面板
contextPanel?.classList.toggle('hidden');
});
// 上下文开关变化
document.getElementById('wechat-context-enabled')?.addEventListener('change', (e) => {
const enabled = e.target.checked;
const settings = extension_settings[extensionName];
settings.contextEnabled = enabled;
saveSettingsDebounced();
// 更新显示
document.getElementById('wechat-context-level-display').textContent = enabled ? '已开启' : '已关闭';
// 切换设置区域状态
const settingsSection = document.getElementById('wechat-context-settings');
if (settingsSection) {
settingsSection.style.opacity = enabled ? '1' : '0.5';
settingsSection.style.pointerEvents = enabled ? 'auto' : 'none';
}
});
// 上下文滑块变化
document.getElementById('wechat-context-slider')?.addEventListener('input', (e) => {
const value = e.target.value;
const settings = extension_settings[extensionName];
settings.contextLevel = parseInt(value);
saveSettingsDebounced();
// 更新显示
document.getElementById('wechat-context-value').textContent = value;
});
// 标签容器事件委托(添加和删除)
document.getElementById('wechat-context-tags')?.addEventListener('click', (e) => {
// 删除标签
if (e.target.classList.contains('wechat-tag-del-btn')) {
const index = parseInt(e.target.dataset.index);
const settings = extension_settings[extensionName];
if (settings.contextTags && index >= 0 && index < settings.contextTags.length) {
settings.contextTags.splice(index, 1);
saveSettingsDebounced();
refreshContextTags();
}
}
// 添加标签
if (e.target.classList.contains('wechat-tag-add-btn')) {
const tagName = prompt('输入标签名(如 content、scene):');
if (tagName && tagName.trim()) {
const settings = extension_settings[extensionName];
if (!settings.contextTags) settings.contextTags = [];
if (!settings.contextTags.includes(tagName.trim())) {
settings.contextTags.push(tagName.trim());
saveSettingsDebounced();
refreshContextTags();
}
}
}
});
// 钱包金额保存(滑出面板)
document.getElementById('wechat-wallet-save-slide')?.addEventListener('click', () => {
const input = document.getElementById('wechat-wallet-input-slide');
const amount = input?.value || '0.00';
const settings = extension_settings[extensionName];
settings.walletAmount = amount;
saveSettingsDebounced();
// 更新显示
document.getElementById('wechat-wallet-amount').textContent = '¥' + amount;
// 关闭面板
document.getElementById('wechat-wallet-panel')?.classList.add('hidden');
});
// 总结API配置 - 密码显示切换
document.getElementById('wechat-summary-key-toggle')?.addEventListener('click', () => {
const input = document.getElementById('wechat-summary-key');
if (input) {
input.type = input.type === 'password' ? 'text' : 'password';
}
});
// 总结API配置 - 获取模型列表
document.getElementById('wechat-summary-fetch-models')?.addEventListener('click', async () => {
const statusEl = document.getElementById('wechat-summary-status');
const urlInput = document.getElementById('wechat-summary-url');
const keyInput = document.getElementById('wechat-summary-key');
const modelSelect = document.getElementById('wechat-summary-model');
const url = urlInput?.value?.trim();
const key = keyInput?.value?.trim();
if (!url || !key) {
if (statusEl) statusEl.textContent = '❌ 请先填写 URL 和 Key';
return;
}
if (statusEl) statusEl.textContent = '⏳ 正在获取模型列表...';
try {
const modelsUrl = url.replace(/\/$/, '') + '/models';
const response = await fetch(modelsUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const models = (data.data || data || [])
.map(m => m.id || m.name || m)
.filter(m => typeof m === 'string')
.sort();
if (models.length === 0) {
if (statusEl) statusEl.textContent = '⚠️ 未找到可用模型';
return;
}
// 更新下拉列表
if (modelSelect) {
modelSelect.innerHTML = '' +
models.map(m => ``).join('');
}
// 保存到设置
const settings = extension_settings[extensionName];
settings.summaryModelList = models;
saveSettingsDebounced();
if (statusEl) statusEl.textContent = `✅ 获取到 ${models.length} 个模型`;
} catch (err) {
console.error('获取模型列表失败:', err);
if (statusEl) statusEl.textContent = `❌ 获取失败: ${err.message}`;
}
});
// 总结API配置 - 测试连接
document.getElementById('wechat-summary-test')?.addEventListener('click', async () => {
const statusEl = document.getElementById('wechat-summary-status');
const urlInput = document.getElementById('wechat-summary-url');
const keyInput = document.getElementById('wechat-summary-key');
const modelSelect = document.getElementById('wechat-summary-model');
const url = urlInput?.value?.trim();
const key = keyInput?.value?.trim();
const model = modelSelect?.value;
if (!url || !key) {
if (statusEl) statusEl.textContent = '❌ 请先填写 URL 和 Key';
return;
}
if (!model) {
if (statusEl) statusEl.textContent = '❌ 请先选择模型';
return;
}
if (statusEl) statusEl.textContent = '⏳ 正在测试连接...';
try {
const chatUrl = url.replace(/\/$/, '') + '/chat/completions';
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: model,
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 5
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error?.message || `HTTP ${response.status}`);
}
if (statusEl) statusEl.textContent = '✅ 连接成功!';
} catch (err) {
console.error('测试连接失败:', err);
if (statusEl) statusEl.textContent = `❌ 连接失败: ${err.message}`;
}
});
// 总结API配置 - 保存配置
document.getElementById('wechat-summary-save')?.addEventListener('click', () => {
const statusEl = document.getElementById('wechat-summary-status');
const urlInput = document.getElementById('wechat-summary-url');
const keyInput = document.getElementById('wechat-summary-key');
const modelSelect = document.getElementById('wechat-summary-model');
const settings = extension_settings[extensionName];
settings.summaryApiUrl = urlInput?.value?.trim() || '';
settings.summaryApiKey = keyInput?.value?.trim() || '';
settings.summarySelectedModel = modelSelect?.value || '';
saveSettingsDebounced();
if (statusEl) statusEl.textContent = '✅ 配置已保存';
// 2秒后关闭面板
setTimeout(() => {
document.getElementById('wechat-summary-panel')?.classList.add('hidden');
}, 1500);
});
// 总结API配置 - 模型选择变化
document.getElementById('wechat-summary-model')?.addEventListener('change', (e) => {
const settings = extension_settings[extensionName];
settings.summarySelectedModel = e.target.value;
saveSettingsDebounced();
});
// 总结API配置 - 执行总结
document.getElementById('wechat-summary-execute')?.addEventListener('click', () => {
executeSummary();
});
// 总结API配置 - 回退总结
document.getElementById('wechat-summary-rollback')?.addEventListener('click', () => {
rollbackSummary();
});
// 总结面板 - 关闭按钮
document.getElementById('wechat-summary-close')?.addEventListener('click', () => {
document.getElementById('wechat-summary-panel')?.classList.add('hidden');
});
// 服务页面 - 服务项点击
document.querySelectorAll('.wechat-service-item').forEach(item => {
item.addEventListener('click', () => {
const service = item.dataset.service;
// 总结功能 - 打开配置面板
if (service === 'summary') {
const panel = document.getElementById('wechat-summary-panel');
if (panel) {
// 关闭其他面板
document.getElementById('wechat-context-panel')?.classList.add('hidden');
document.getElementById('wechat-wallet-panel')?.classList.add('hidden');
// 切换当前面板
panel.classList.toggle('hidden');
}
return;
}
// 其他功能暂未实现
showToast(`"${item.querySelector('span').textContent}" 功能开发中...`, '🚧');
});
});
// 收藏页面 - 添加世界书按钮
document.getElementById('wechat-favorites-add-btn')?.addEventListener('click', () => {
showLorebookModal();
});
// 世界书选择弹窗取消
document.getElementById('wechat-lorebook-cancel')?.addEventListener('click', () => {
document.getElementById('wechat-lorebook-modal').classList.add('hidden');
});
// 收藏页面标签切换
document.querySelectorAll('.wechat-favorites-tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.wechat-favorites-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
refreshFavoritesList(this.dataset.tab);
});
});
// 清空联系人
document.getElementById('wechat-clear-contacts')?.addEventListener('click', () => {
if (confirm('确定要清空所有联系人吗?')) {
extension_settings[extensionName].contacts = [];
saveSettingsDebounced();
refreshContactsList();
showToast('已清空所有联系人');
}
});
// 用户头像点击更换
document.getElementById('wechat-me-avatar')?.addEventListener('click', () => {
document.getElementById('wechat-user-avatar-input')?.click();
});
// 用户头像文件选择
document.getElementById('wechat-user-avatar-input')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const reader = new FileReader();
reader.onload = function(event) {
const settings = extension_settings[extensionName];
settings.userAvatar = event.target.result;
saveSettingsDebounced();
updateMePageInfo();
showToast('头像已更换');
};
reader.readAsDataURL(file);
} catch (err) {
console.error('更换头像失败:', err);
showToast('更换头像失败: ' + err.message, '❌');
}
e.target.value = ''; // 清空以便重复选择同一文件
});
// API 配置相关事件
// 切换密钥可见性
document.getElementById('wechat-toggle-key-visibility')?.addEventListener('click', () => {
const keyInput = document.getElementById('wechat-api-key');
const eyeBtn = document.getElementById('wechat-toggle-key-visibility');
if (keyInput.type === 'password') {
keyInput.type = 'text';
eyeBtn.innerHTML = '';
} else {
keyInput.type = 'password';
eyeBtn.innerHTML = '';
}
});
// 保存 API 配置
document.getElementById('wechat-save-api')?.addEventListener('click', () => {
const apiUrl = document.getElementById('wechat-api-url').value.trim();
const apiKey = document.getElementById('wechat-api-key').value.trim();
const selectedModel = document.getElementById('wechat-model-select')?.value || '';
extension_settings[extensionName].apiUrl = apiUrl;
extension_settings[extensionName].apiKey = apiKey;
extension_settings[extensionName].selectedModel = selectedModel;
saveSettingsDebounced();
showToast('API 配置已保存');
});
// 刷新模型列表
document.getElementById('wechat-refresh-models')?.addEventListener('click', () => {
refreshModelSelect();
});
// 模型选择变化
document.getElementById('wechat-model-select')?.addEventListener('change', (e) => {
extension_settings[extensionName].selectedModel = e.target.value;
saveSettingsDebounced();
});
// 测试 API 连接
document.getElementById('wechat-test-api')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('wechat-api-url').value.trim();
const apiKey = document.getElementById('wechat-api-key').value.trim();
if (!apiUrl) {
showToast('请先填写 API 地址', '⚠️');
return;
}
const testBtn = document.getElementById('wechat-test-api');
const originalText = testBtn.textContent;
testBtn.textContent = '测试中...';
testBtn.disabled = true;
try {
const result = await testApiConnection(apiUrl, apiKey);
if (result.success) {
showToast('连接成功');
} else {
showToast('连接失败:' + (result.message || '未知错误'), '❌');
}
} catch (err) {
showToast('连接失败:' + err.message, '❌');
} finally {
testBtn.textContent = originalText;
testBtn.disabled = false;
}
});
// 弹窗取消
document.getElementById('wechat-import-cancel')?.addEventListener('click', () => {
document.getElementById('wechat-import-modal').classList.add('hidden');
pendingImport = null;
});
// 弹窗确认
document.getElementById('wechat-import-confirm')?.addEventListener('click', async () => {
if (pendingImport) {
try {
// 添加到联系人
if (addContact(pendingImport)) {
// 尝试导入到 SillyTavern
try {
await importCharacterToST(pendingImport);
showToast(`${pendingImport.name} 已添加`);
} catch (err) {
showToast(`${pendingImport.name} 已添加,导入酒馆失败`, '⚠️');
}
}
} catch (err) {
showToast('添加失败:' + err.message, '❌');
}
document.getElementById('wechat-import-modal').classList.add('hidden');
pendingImport = null;
showPage('wechat-main-content');
}
});
// 绑定联系人点击
bindContactsEvents();
}
// 待导入的角色数据
let pendingImport = null;
// 显示导入确认弹窗
function showImportModal(charData) {
pendingImport = charData;
const preview = document.getElementById('wechat-card-preview');
preview.innerHTML = `
${charData.avatar ? `

` : charData.name.charAt(0)}
${charData.name}
${charData.description?.substring(0, 200) || '暂无简介'}
`;
document.getElementById('wechat-import-modal').classList.remove('hidden');
}
// 监听聊天消息更新
function setupMessageObserver() {
const context = getContext();
if (!context) return;
// 监听新消息
const chatContainer = document.getElementById('chat');
if (chatContainer) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.classList?.contains('mes')) {
// 检查是否包含微信格式
const mesText = node.querySelector('.mes_text');
if (mesText) {
const wechatMessages = parseWeChatMessage(mesText.textContent);
if (wechatMessages.length > 0) {
// 可以在这里添加微信消息的特殊显示
console.log('检测到微信格式消息:', wechatMessages);
}
}
}
});
});
});
observer.observe(chatContainer, { childList: true, subtree: true });
}
}
// 添加扩展按钮到酒馆魔法棒菜单
function addExtensionButton() {
// 添加到扩展菜单 (extensionsMenu)
const extensionsMenu = document.getElementById('extensionsMenu');
if (extensionsMenu && !document.getElementById('wechat-extension-menu-item')) {
const menuItem = document.createElement('div');
menuItem.id = 'wechat-extension-menu-item';
menuItem.className = 'list-group-item flex-container flexGap5';
menuItem.innerHTML = `
可乐
`;
menuItem.style.cursor = 'pointer';
menuItem.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePhone();
// 关闭扩展菜单
const menu = document.getElementById('extensionsMenu');
if (menu) menu.style.display = 'none';
});
extensionsMenu.appendChild(menuItem);
}
}
// 初始化插件
jQuery(async () => {
loadSettings();
// 添加 HTML 到页面
const phoneHTML = generatePhoneHTML();
$('body').append(phoneHTML);
setupPhoneAutoCentering();
setupPhoneDrag();
// 绑定事件
bindEvents();
// 恢复模型列表
restoreModelSelect();
// 设置消息监听
setupMessageObserver();
// 添加扩展按钮到酒馆魔法棒菜单
addExtensionButton();
// 更新时间
setInterval(() => {
const timeEl = document.querySelector('.wechat-statusbar-time');
if (timeEl && !document.getElementById('wechat-phone').classList.contains('hidden')) {
timeEl.textContent = getCurrentTime();
}
}, 60000);
console.log('✅ 可乐不加冰 v1.0.0 已加载');
});