Add files via upload

This commit is contained in:
Cola-Echo
2025-12-22 02:41:32 +08:00
committed by GitHub
commit 1e1bf1bab2
30 changed files with 34640 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

1084
ai.js Normal file

File diff suppressed because it is too large Load Diff

123
character-import.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* 角色卡导入:从 PNG/JSON 解析 + 导入到 SillyTavern
*/
import { getRequestHeaders } from '../../../../script.js';
// 从 PNG 提取角色卡数据 (V2 格式)
export 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);
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 文件');
}
}
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);
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);
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;
}
throw new Error('PNG 文件中未找到角色卡数据');
} catch (err) {
reject(err);
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
}
// 从 JSON 导入角色卡
export 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
export async function importCharacterToST(characterData) {
try {
const formData = new FormData();
if (characterData.file) {
formData.append('avatar', characterData.file);
}
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;
}
}

430
chat-background.js Normal file
View File

@@ -0,0 +1,430 @@
/**
* 聊天背景功能模块
* 支持每个联系人独立设置背景,含图片裁剪功能
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings } from './config.js';
import { showToast } from './toast.js';
import { currentChatIndex } from './chat.js';
// 裁剪器状态
let cropperState = {
image: null,
canvas: null,
ctx: null,
imageX: 0,
imageY: 0,
imageWidth: 0,
imageHeight: 0,
cropBox: { x: 50, y: 50, width: 200, height: 300 },
isDragging: false,
isResizing: false,
resizeHandle: null,
dragStart: { x: 0, y: 0 },
boxStart: { x: 0, y: 0, width: 0, height: 0 }
};
// 初始化聊天背景功能
export function initChatBackground() {
// 背景面板相关事件
document.getElementById('wechat-menu-chat-bg')?.addEventListener('click', () => {
document.getElementById('wechat-chat-menu')?.classList.add('hidden');
showChatBgPanel();
});
document.getElementById('wechat-chat-bg-close')?.addEventListener('click', () => {
document.getElementById('wechat-chat-bg-panel')?.classList.add('hidden');
});
document.getElementById('wechat-chat-bg-upload')?.addEventListener('click', () => {
document.getElementById('wechat-chat-bg-file')?.click();
});
document.getElementById('wechat-chat-bg-file')?.addEventListener('change', handleBgFileSelect);
document.getElementById('wechat-chat-bg-clear')?.addEventListener('click', clearChatBackground);
// 裁剪器事件
document.getElementById('wechat-cropper-cancel')?.addEventListener('click', closeCropper);
document.getElementById('wechat-cropper-confirm')?.addEventListener('click', confirmCrop);
// 裁剪框拖拽事件
const cropperBox = document.getElementById('wechat-cropper-box');
if (cropperBox) {
cropperBox.addEventListener('mousedown', handleCropBoxMouseDown);
cropperBox.addEventListener('touchstart', handleCropBoxTouchStart, { passive: false });
}
// 全局移动和释放事件
document.addEventListener('mousemove', handleCropperMouseMove);
document.addEventListener('mouseup', handleCropperMouseUp);
document.addEventListener('touchmove', handleCropperTouchMove, { passive: false });
document.addEventListener('touchend', handleCropperTouchEnd);
// 调整大小手柄
document.querySelectorAll('.wechat-cropper-handle').forEach(handle => {
handle.addEventListener('mousedown', (e) => handleResizeStart(e, handle));
handle.addEventListener('touchstart', (e) => handleResizeTouchStart(e, handle), { passive: false });
});
}
// 显示背景设置面板
export function showChatBgPanel() {
const panel = document.getElementById('wechat-chat-bg-panel');
const preview = document.getElementById('wechat-chat-bg-preview');
if (!panel || !preview) return;
// 获取当前联系人的背景
const settings = getSettings();
const contact = settings.contacts[currentChatIndex];
if (contact?.chatBackground) {
preview.innerHTML = `<img src="${contact.chatBackground}" alt="背景预览">`;
} else {
preview.innerHTML = '<span class="wechat-chat-bg-placeholder">暂无背景</span>';
}
// 关闭其他面板
document.getElementById('wechat-recalled-panel')?.classList.add('hidden');
panel.classList.remove('hidden');
}
// 处理背景图片选择
async function handleBgFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
try {
const reader = new FileReader();
reader.onload = function(event) {
openCropper(event.target.result);
};
reader.readAsDataURL(file);
} catch (err) {
console.error('[可乐] 读取背景图片失败:', err);
showToast('读取图片失败', '⚠️');
}
e.target.value = '';
}
// 打开裁剪器
function openCropper(imageSrc) {
const modal = document.getElementById('wechat-cropper-modal');
const canvas = document.getElementById('wechat-cropper-canvas');
const container = document.getElementById('wechat-cropper-container');
if (!modal || !canvas || !container) return;
const img = new Image();
img.onload = function() {
cropperState.image = img;
cropperState.canvas = canvas;
cropperState.ctx = canvas.getContext('2d');
// 计算画布尺寸(适应容器)
const containerRect = container.getBoundingClientRect();
const containerWidth = containerRect.width || 300;
const containerHeight = 300;
const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
const displayWidth = img.width * scale;
const displayHeight = img.height * scale;
canvas.width = displayWidth;
canvas.height = displayHeight;
cropperState.imageWidth = displayWidth;
cropperState.imageHeight = displayHeight;
cropperState.imageX = (containerWidth - displayWidth) / 2;
cropperState.imageY = (containerHeight - displayHeight) / 2;
// 绘制图片
cropperState.ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
// 初始化裁剪框居中9:16比例
const boxHeight = Math.min(displayHeight * 0.8, 250);
const boxWidth = boxHeight * 9 / 16;
cropperState.cropBox = {
x: (displayWidth - boxWidth) / 2,
y: (displayHeight - boxHeight) / 2,
width: boxWidth,
height: boxHeight
};
updateCropBoxUI();
modal.classList.remove('hidden');
};
img.onerror = function() {
showToast('图片加载失败', '⚠️');
};
img.src = imageSrc;
}
// 更新裁剪框UI
function updateCropBoxUI() {
const cropBox = document.getElementById('wechat-cropper-box');
const canvas = cropperState.canvas;
if (!cropBox || !canvas) return;
// 获取画布在容器中的位置
const container = document.getElementById('wechat-cropper-container');
const containerRect = container.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
const offsetX = canvasRect.left - containerRect.left;
const offsetY = canvasRect.top - containerRect.top;
cropBox.style.left = (cropperState.cropBox.x + offsetX) + 'px';
cropBox.style.top = (cropperState.cropBox.y + offsetY) + 'px';
cropBox.style.width = cropperState.cropBox.width + 'px';
cropBox.style.height = cropperState.cropBox.height + 'px';
}
// 裁剪框拖拽开始
function handleCropBoxMouseDown(e) {
if (e.target.classList.contains('wechat-cropper-handle')) return;
e.preventDefault();
cropperState.isDragging = true;
cropperState.dragStart = { x: e.clientX, y: e.clientY };
cropperState.boxStart = { ...cropperState.cropBox };
}
function handleCropBoxTouchStart(e) {
if (e.target.classList.contains('wechat-cropper-handle')) return;
e.preventDefault();
const touch = e.touches[0];
cropperState.isDragging = true;
cropperState.dragStart = { x: touch.clientX, y: touch.clientY };
cropperState.boxStart = { ...cropperState.cropBox };
}
// 调整大小开始
function handleResizeStart(e, handle) {
e.preventDefault();
e.stopPropagation();
cropperState.isResizing = true;
cropperState.resizeHandle = handle.classList.contains('nw') ? 'nw' :
handle.classList.contains('ne') ? 'ne' :
handle.classList.contains('sw') ? 'sw' : 'se';
cropperState.dragStart = { x: e.clientX, y: e.clientY };
cropperState.boxStart = { ...cropperState.cropBox };
}
function handleResizeTouchStart(e, handle) {
e.preventDefault();
e.stopPropagation();
const touch = e.touches[0];
cropperState.isResizing = true;
cropperState.resizeHandle = handle.classList.contains('nw') ? 'nw' :
handle.classList.contains('ne') ? 'ne' :
handle.classList.contains('sw') ? 'sw' : 'se';
cropperState.dragStart = { x: touch.clientX, y: touch.clientY };
cropperState.boxStart = { ...cropperState.cropBox };
}
// 鼠标移动
function handleCropperMouseMove(e) {
if (!cropperState.isDragging && !cropperState.isResizing) return;
const dx = e.clientX - cropperState.dragStart.x;
const dy = e.clientY - cropperState.dragStart.y;
if (cropperState.isDragging) {
moveCropBox(dx, dy);
} else if (cropperState.isResizing) {
resizeCropBox(dx, dy);
}
}
function handleCropperTouchMove(e) {
if (!cropperState.isDragging && !cropperState.isResizing) return;
e.preventDefault();
const touch = e.touches[0];
const dx = touch.clientX - cropperState.dragStart.x;
const dy = touch.clientY - cropperState.dragStart.y;
if (cropperState.isDragging) {
moveCropBox(dx, dy);
} else if (cropperState.isResizing) {
resizeCropBox(dx, dy);
}
}
// 移动裁剪框
function moveCropBox(dx, dy) {
let newX = cropperState.boxStart.x + dx;
let newY = cropperState.boxStart.y + dy;
// 限制在画布范围内
newX = Math.max(0, Math.min(newX, cropperState.imageWidth - cropperState.cropBox.width));
newY = Math.max(0, Math.min(newY, cropperState.imageHeight - cropperState.cropBox.height));
cropperState.cropBox.x = newX;
cropperState.cropBox.y = newY;
updateCropBoxUI();
}
// 调整裁剪框大小
function resizeCropBox(dx, dy) {
const minSize = 50;
const handle = cropperState.resizeHandle;
let { x, y, width, height } = cropperState.boxStart;
if (handle === 'se') {
width = Math.max(minSize, width + dx);
height = Math.max(minSize, height + dy);
} else if (handle === 'sw') {
const newWidth = Math.max(minSize, width - dx);
x = x + (width - newWidth);
width = newWidth;
height = Math.max(minSize, height + dy);
} else if (handle === 'ne') {
width = Math.max(minSize, width + dx);
const newHeight = Math.max(minSize, height - dy);
y = y + (height - newHeight);
height = newHeight;
} else if (handle === 'nw') {
const newWidth = Math.max(minSize, width - dx);
const newHeight = Math.max(minSize, height - dy);
x = x + (width - newWidth);
y = y + (height - newHeight);
width = newWidth;
height = newHeight;
}
// 限制在画布范围内
x = Math.max(0, x);
y = Math.max(0, y);
if (x + width > cropperState.imageWidth) width = cropperState.imageWidth - x;
if (y + height > cropperState.imageHeight) height = cropperState.imageHeight - y;
cropperState.cropBox = { x, y, width, height };
updateCropBoxUI();
}
// 鼠标释放
function handleCropperMouseUp() {
cropperState.isDragging = false;
cropperState.isResizing = false;
}
function handleCropperTouchEnd() {
cropperState.isDragging = false;
cropperState.isResizing = false;
}
// 关闭裁剪器
function closeCropper() {
document.getElementById('wechat-cropper-modal')?.classList.add('hidden');
cropperState.image = null;
}
// 确认裁剪
function confirmCrop() {
if (!cropperState.image || !cropperState.canvas) {
showToast('裁剪失败', '⚠️');
return;
}
// 计算原图裁剪区域
const scaleX = cropperState.image.width / cropperState.imageWidth;
const scaleY = cropperState.image.height / cropperState.imageHeight;
const cropX = cropperState.cropBox.x * scaleX;
const cropY = cropperState.cropBox.y * scaleY;
const cropWidth = cropperState.cropBox.width * scaleX;
const cropHeight = cropperState.cropBox.height * scaleY;
// 创建裁剪后的画布
const outputCanvas = document.createElement('canvas');
outputCanvas.width = cropWidth;
outputCanvas.height = cropHeight;
const outputCtx = outputCanvas.getContext('2d');
outputCtx.drawImage(
cropperState.image,
cropX, cropY, cropWidth, cropHeight,
0, 0, cropWidth, cropHeight
);
// 转为DataURL并保存
const croppedImage = outputCanvas.toDataURL('image/jpeg', 0.85);
saveChatBackground(croppedImage);
closeCropper();
document.getElementById('wechat-chat-bg-panel')?.classList.add('hidden');
}
// 保存聊天背景
function saveChatBackground(imageData) {
const settings = getSettings();
const contact = settings.contacts[currentChatIndex];
if (!contact) {
showToast('保存失败', '⚠️');
return;
}
contact.chatBackground = imageData;
saveSettingsDebounced();
// 立即应用背景
applyChatBackground(imageData);
showToast('背景已设置');
}
// 清除聊天背景
function clearChatBackground() {
const settings = getSettings();
const contact = settings.contacts[currentChatIndex];
if (!contact) return;
delete contact.chatBackground;
saveSettingsDebounced();
// 清除背景
applyChatBackground(null);
// 更新预览
const preview = document.getElementById('wechat-chat-bg-preview');
if (preview) {
preview.innerHTML = '<span class="wechat-chat-bg-placeholder">暂无背景</span>';
}
document.getElementById('wechat-chat-bg-panel')?.classList.add('hidden');
showToast('背景已清除');
}
// 应用聊天背景
export function applyChatBackground(imageData) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
if (imageData) {
messagesContainer.style.backgroundImage = `url(${imageData})`;
} else {
messagesContainer.style.backgroundImage = '';
}
}
// 加载当前联系人的背景openChat时调用
export function loadContactBackground(contactIndex) {
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (contact?.chatBackground) {
applyChatBackground(contact.chatBackground);
} else {
applyChatBackground(null);
}
}

727
chat-func-panel.js Normal file
View File

@@ -0,0 +1,727 @@
/**
* 聊天页功能面板 + 展开输入(语音/多条消息/混合消息)
*/
import { calculateVoiceDuration, escapeHtml, sleep } from './utils.js';
import { showToast } from './toast.js';
import { sendMessage, sendPhotoMessage, sendBatchMessages, appendMusicCardMessage, currentChatIndex, appendMessage, showTypingIndicator, hideTypingIndicator, parseAiQuoteMessage, detectAiCallRequest } from './chat.js';
import { isInGroupChat, sendGroupMessage, sendGroupPhotoMessage, sendGroupBatchMessages, getCurrentGroupIndex, appendGroupMessage, showGroupTypingIndicator, hideGroupTypingIndicator, callGroupAI, enforceGroupChatMemberLimit, appendGroupMusicCardMessage } from './group-chat.js';
import { startVoiceCall } from './voice-call.js';
import { startVideoCall } from './video-call.js';
import { showMusicPanel, initMusicEvents } from './music.js';
import { getSettings, splitAIMessages } from './config.js';
import { refreshChatList } from './ui.js';
import { saveSettingsDebounced } from '../../../../script.js';
import { callAI } from './ai.js';
let expandMode = null; // 'voice' | 'multi' | null
// 混合消息项: { type: 'text' | 'voice' | 'sticker' | 'photo', content: string }
let expandMsgItems = [{ type: 'text', content: '' }];
let funcPanelPage = 0;
let funcPanelInited = false;
// 临时存储待插入的表情URL
let pendingStickerIndex = -1;
let musicShareListenerInited = false;
function safeText(value) {
return value == null ? '' : String(value).trim();
}
function clipText(text, maxChars) {
const raw = safeText(text);
if (!raw) return '';
if (raw.length <= maxChars) return raw;
return raw.slice(0, maxChars - 1) + '…';
}
function clipLyrics(lyrics) {
const raw = safeText(lyrics);
if (!raw) return '';
// 移除时间标签,只保留歌词文本
const lines = raw.split(/\r?\n/)
.map(line => line.replace(/^\[\d{2}:\d{2}[.\d]*\]/g, '').trim())
.filter(line => line);
const limitedLines = lines.slice(0, 30).join('\n');
return clipText(limitedLines, 800);
}
function formatMusicShareMessage(song) {
const name = safeText(song?.name) || '未知歌曲';
const artist = safeText(song?.artist);
const lyrics = clipLyrics(song?.lyrics);
let message = `[分享音乐] ${name}`;
if (artist) message += ` - ${artist}`;
if (lyrics) message += `\n\n${lyrics}`;
return message;
}
function initMusicShareListener() {
if (musicShareListenerInited) return;
musicShareListenerInited = true;
document.addEventListener('music-share', async (e) => {
const song = e?.detail;
if (!song) return;
const settings = getSettings();
const groupIndex = getCurrentGroupIndex();
// 构建给AI的消息包含歌名歌手和歌词
const name = safeText(song?.name) || '未知歌曲';
const artist = safeText(song?.artist);
const lyrics = clipLyrics(song?.lyrics);
let aiMessage = `[分享音乐] ${name}`;
if (artist) aiMessage += ` - ${artist}`;
if (lyrics) aiMessage += `\n歌词:\n${lyrics}`;
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')}`;
// 群聊分享音乐
if (groupIndex >= 0) {
const groupChat = settings.groupChats?.[groupIndex];
if (!groupChat) return;
if (!Array.isArray(groupChat.chatHistory)) {
groupChat.chatHistory = [];
}
// 显示音乐卡片
appendGroupMusicCardMessage('user', song);
// 保存到聊天历史
groupChat.chatHistory.push({
role: 'user',
content: aiMessage,
time: timeStr,
timestamp: Date.now(),
isMusic: true,
musicInfo: { name: song.name, artist: song.artist, platform: song.platform, cover: song.cover, id: song.id }
});
groupChat.lastMessage = `[音乐] ${name}`;
groupChat.lastMessageTime = Date.now();
saveSettingsDebounced();
refreshChatList();
// 获取成员信息
const { memberIds } = enforceGroupChatMemberLimit(groupChat);
const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean);
if (members.length === 0) {
showToast('群聊成员不存在', '⚠️');
return;
}
// 显示打字指示器
showGroupTypingIndicator(members[0]?.name, members[0]?.id);
try {
// 调用群聊AI
const responses = await callGroupAI(groupChat, members, aiMessage, []);
hideGroupTypingIndicator();
// 逐条显示AI回复
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
// 显示typing指示器并等待
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400);
hideGroupTypingIndicator();
// 保存并显示消息
groupChat.chatHistory.push({
role: 'assistant',
content: resp.content,
time: timeStr,
timestamp: Date.now(),
characterName: resp.characterName,
characterId: resp.characterId
});
appendGroupMessage('assistant', resp.content, resp.characterName, resp.characterId);
}
if (responses.length > 0) {
const lastResp = responses[responses.length - 1];
groupChat.lastMessage = lastResp.content.length > 20 ? lastResp.content.substring(0, 20) + '...' : lastResp.content;
groupChat.lastMessageTime = Date.now();
}
saveSettingsDebounced();
refreshChatList();
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊音乐分享AI回复失败:', err);
}
return;
}
// 单聊分享音乐
if (currentChatIndex < 0) return;
const contactIndex = currentChatIndex;
const contact = settings.contacts[contactIndex];
if (!contact) return;
if (!contact.chatHistory) {
contact.chatHistory = [];
}
// 显示音乐卡片
appendMusicCardMessage('user', song, contact);
// 保存到聊天历史
contact.chatHistory.push({
role: 'user',
content: aiMessage,
time: timeStr,
timestamp: Date.now(),
isMusic: true,
musicInfo: { name: song.name, artist: song.artist, platform: song.platform, cover: song.cover, id: song.id }
});
contact.lastMessage = `[音乐] ${name}`;
saveSettingsDebounced();
refreshChatList();
// 调用AI回复
showTypingIndicator(contact);
try {
const aiReply = await callAI(contact, aiMessage);
hideTypingIndicator();
if (aiReply) {
// 使用 splitAIMessages 分割AI回复
const aiMessages = splitAIMessages(aiReply);
let lastShownMessage = null;
for (let i = 0; i < aiMessages.length; i++) {
const rawMsg = aiMessages[i];
// 兼容 AI 发起通话请求(如:[通话请求] / [语音通话请求] / [视频通话请求]),不显示为文本
const callRequestType = detectAiCallRequest(rawMsg);
if (callRequestType === 'voice') {
startVoiceCall('ai', contactIndex);
break; // 通话请求必须单独一条
}
if (callRequestType === 'video') {
startVideoCall('ai', contactIndex);
break; // 通话请求必须单独一条
}
// 解析 [回复:xxx] 引用格式,避免把标记直接显示出来
const parsed = parseAiQuoteMessage(rawMsg, contact);
const msg = (parsed?.content || '').toString().trim();
const quote = parsed?.quote || null;
if (!msg) continue;
contact.chatHistory.push({
role: 'assistant',
content: msg,
time: timeStr,
timestamp: Date.now(),
quote: quote || undefined
});
appendMessage('assistant', msg, contact, false, quote);
lastShownMessage = msg;
}
if (lastShownMessage) {
contact.lastMessage = lastShownMessage.length > 20 ? lastShownMessage.substring(0, 20) + '...' : lastShownMessage;
}
saveSettingsDebounced();
refreshChatList();
}
} catch (err) {
hideTypingIndicator();
console.error('[可乐] 音乐分享AI回复失败:', err);
}
});
}
export 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');
if (!panel || !title || !body) return;
title.textContent = '语音消息';
body.innerHTML = `
<div class="wechat-expand-hint">输入语音内容,系统会根据字数计算时长</div>
<textarea class="wechat-expand-textarea" id="wechat-expand-voice-text" placeholder="输入语音内容..."></textarea>
<div class="wechat-expand-preview">
<span class="wechat-expand-preview-label">预计时长:</span>
<span class="wechat-expand-preview-value" id="wechat-expand-voice-duration">0"</span>
</div>
`;
panel.classList.remove('hidden');
const textarea = document.getElementById('wechat-expand-voice-text');
textarea?.addEventListener('input', updateExpandVoiceDuration);
setTimeout(() => textarea?.focus(), 50);
}
// 显示照片描述输入面板
export function showExpandPhoto() {
expandMode = 'photo';
const panel = document.getElementById('wechat-expand-input');
const title = document.getElementById('wechat-expand-title');
const body = document.getElementById('wechat-expand-body');
if (!panel || !title || !body) return;
title.textContent = '发送照片';
body.innerHTML = `
<textarea class="wechat-expand-textarea" id="wechat-expand-photo-text" placeholder="描述照片内容..."></textarea>
`;
panel.classList.remove('hidden');
const textarea = document.getElementById('wechat-expand-photo-text');
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) return;
const content = textarea.value.trim();
const duration = content ? calculateVoiceDuration(content) : 0;
durationEl.textContent = duration + '"';
}
export function showExpandMulti() {
expandMode = 'multi';
expandMsgItems = [{ type: 'text', content: '' }];
const panel = document.getElementById('wechat-expand-input');
const title = document.getElementById('wechat-expand-title');
if (!panel || !title) return;
title.textContent = '混合消息';
renderExpandMsgList();
panel.classList.remove('hidden');
setTimeout(() => {
const firstInput = document.querySelector('.wechat-expand-msg-input');
firstInput?.focus();
}, 50);
}
// 获取消息类型的线条图标
function getTypeIcon(type) {
switch (type) {
case 'voice':
return `<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2a3 3 0 00-3 3v6a3 3 0 006 0V5a3 3 0 00-3-3z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v1a7 7 0 01-14 0v-1M12 18v3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>`;
case 'sticker':
return `<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="10" r="1" fill="currentColor"/><circle cx="15" cy="10" r="1" fill="currentColor"/><path d="M8 14c1 2 2.5 3 4 3s3-1 4-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>`;
case 'photo':
return `<svg viewBox="0 0 24 24" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
default: // text
return `<svg viewBox="0 0 24 24" width="16" height="16"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>`;
}
}
// 获取消息类型标签
function getTypeLabel(type) {
switch (type) {
case 'voice': return '语音';
case 'sticker': return '表情';
case 'photo': return '照片';
default: return '文字';
}
}
function renderExpandMsgList() {
const body = document.getElementById('wechat-expand-body');
if (!body) return;
let html = '<div class=\"wechat-expand-msg-list\" id=\"wechat-expand-msg-list\">';
expandMsgItems.forEach((item, index) => {
const typeIcon = getTypeIcon(item.type);
const typeLabel = getTypeLabel(item.type);
html += `
<div class=\"wechat-expand-msg-item\" data-index=\"${index}\">
<span class=\"wechat-expand-msg-num\">${index + 1}</span>
<div class=\"wechat-expand-msg-type\" data-index=\"${index}\" title=\"点击切换类型\">
<span class=\"wechat-expand-type-icon\">${typeIcon}</span>
<span class=\"wechat-expand-type-label\">${typeLabel}</span>
</div>
`;
if (item.type === 'sticker') {
// 表情类型:显示选择按钮或已选的表情预览
if (item.content) {
html += `
<div class=\"wechat-expand-sticker-preview\" data-index=\"${index}\">
<img src=\"${escapeHtml(item.content)}\" alt=\"表情\" style=\"max-width: 50px; max-height: 50px; border-radius: 4px;\">
<button class=\"wechat-expand-sticker-change\" data-index=\"${index}\" title=\"更换表情\">换</button>
</div>
`;
} else {
html += `
<button class=\"wechat-expand-sticker-select\" data-index=\"${index}\">选择表情</button>
`;
}
} else if (item.type === 'photo') {
// 照片类型:输入图片描述
html += `
<input type=\"text\" class=\"wechat-expand-msg-input wechat-expand-photo-input\" data-index=\"${index}\" value=\"${escapeHtml(item.content)}\" placeholder=\"输入图片描述...\">
<span class=\"wechat-expand-photo-hint\"><svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/><path d=\"M21 15l-5-5L5 21\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/></svg></span>
`;
} else if (item.type === 'voice') {
// 语音类型:输入框 + 时长显示
html += `
<input type=\"text\" class=\"wechat-expand-msg-input wechat-expand-voice-input\" data-index=\"${index}\" value=\"${escapeHtml(item.content)}\" placeholder=\"输入语音内容...\">
<span class=\"wechat-expand-voice-dur\">${item.content ? calculateVoiceDuration(item.content) + '\"' : '0\"'}</span>
`;
} else {
// 文字类型:普通输入框
html += `
<input type=\"text\" class=\"wechat-expand-msg-input\" data-index=\"${index}\" value=\"${escapeHtml(item.content)}\" placeholder=\"消息 ${index + 1}\">
`;
}
if (expandMsgItems.length > 1) {
html += `<button class=\"wechat-expand-msg-del\" data-index=\"${index}\">✕</button>`;
}
html += `</div>`;
});
html += '</div>';
html += '<button class=\"wechat-expand-add-btn\" id=\"wechat-expand-add-msg\">+ 添加消息</button>';
body.innerHTML = html;
// 绑定输入事件
document.querySelectorAll('.wechat-expand-msg-input').forEach(input => {
input.addEventListener('input', (e) => {
const index = parseInt(e.target.dataset.index);
expandMsgItems[index].content = e.target.value;
// 更新语音时长显示
if (expandMsgItems[index].type === 'voice') {
const durEl = e.target.parentElement.querySelector('.wechat-expand-voice-dur');
if (durEl) {
const duration = e.target.value.trim() ? calculateVoiceDuration(e.target.value) : 0;
durEl.textContent = duration + '\"';
}
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addExpandMsgItem();
}
});
});
// 绑定类型切换事件
document.querySelectorAll('.wechat-expand-msg-type').forEach(typeBtn => {
typeBtn.addEventListener('click', (e) => {
const index = parseInt(typeBtn.dataset.index);
cycleMessageType(index);
});
});
// 绑定删除事件
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.querySelectorAll('.wechat-expand-sticker-select, .wechat-expand-sticker-change').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(btn.dataset.index);
openStickerPickerForMultiMsg(index);
});
});
document.getElementById('wechat-expand-add-msg')?.addEventListener('click', addExpandMsgItem);
}
// 循环切换消息类型
function cycleMessageType(index) {
const currentType = expandMsgItems[index].type;
let newType;
if (currentType === 'text') {
newType = 'voice';
} else if (currentType === 'voice') {
newType = 'sticker';
} else if (currentType === 'sticker') {
newType = 'photo';
} else {
newType = 'text';
}
expandMsgItems[index] = { type: newType, content: '' };
renderExpandMsgList();
}
function addExpandMsgItem() {
expandMsgItems.push({ type: 'text', content: '' });
renderExpandMsgList();
setTimeout(() => {
const inputs = document.querySelectorAll('.wechat-expand-msg-input');
const lastInput = inputs[inputs.length - 1];
lastInput?.focus();
}, 50);
}
// 打开表情选择器用于混合消息
function openStickerPickerForMultiMsg(index) {
pendingStickerIndex = index;
// 关闭展开面板,打开表情面板
const expandPanel = document.getElementById('wechat-expand-input');
const emojiPanel = document.getElementById('wechat-emoji-panel');
expandPanel?.classList.add('hidden');
emojiPanel?.classList.remove('hidden');
// 切换到贴纸标签
const stickerTab = document.querySelector('.wechat-emoji-tab[data-tab="sticker"]');
stickerTab?.click();
showToast('请选择表情', '😊');
}
// 为混合消息设置表情由emoji-panel调用
export function setStickerForMultiMsg(stickerUrl) {
if (pendingStickerIndex < 0 || pendingStickerIndex >= expandMsgItems.length) {
return false;
}
expandMsgItems[pendingStickerIndex].content = stickerUrl;
const savedIndex = pendingStickerIndex;
pendingStickerIndex = -1;
// 关闭表情面板,重新打开展开面板
const emojiPanel = document.getElementById('wechat-emoji-panel');
emojiPanel?.classList.add('hidden');
// 重新显示混合消息面板
expandMode = 'multi';
const panel = document.getElementById('wechat-expand-input');
const title = document.getElementById('wechat-expand-title');
if (panel && title) {
title.textContent = '混合消息';
renderExpandMsgList();
panel.classList.remove('hidden');
}
return true;
}
// 检查是否有待选表情
export function hasPendingStickerSelection() {
return pendingStickerIndex >= 0;
}
export function closeExpandPanel() {
const panel = document.getElementById('wechat-expand-input');
panel?.classList.add('hidden');
expandMode = null;
}
export async function sendExpandContent() {
const inGroup = isInGroupChat();
if (expandMode === 'voice') {
const textarea = document.getElementById('wechat-expand-voice-text');
const content = textarea?.value.trim();
if (!content) {
showToast('请输入语音内容', '🧊');
return;
}
closeExpandPanel();
if (inGroup) {
sendGroupMessage(content, false, true);
} else {
sendMessage(content, false, true);
}
return;
}
if (expandMode === 'photo') {
const textarea = document.getElementById('wechat-expand-photo-text');
const content = textarea?.value.trim();
if (!content) {
showToast('请输入照片描述', '🧊');
return;
}
closeExpandPanel();
if (inGroup) {
await sendGroupPhotoMessage(content);
} else {
await sendPhotoMessage(content);
}
return;
}
if (expandMode === 'multi') {
// 过滤有效消息(文字/语音需要有内容表情需要有URL
const validMessages = expandMsgItems.filter(m => {
if (m.type === 'sticker') {
return m.content && m.content.trim();
}
return m.content && m.content.trim();
});
if (validMessages.length === 0) {
showToast('请至少输入一条消息', '🧊');
return;
}
closeExpandPanel();
// 使用批量发送函数一次性发完再调用AI
if (inGroup) {
await sendGroupBatchMessages(validMessages);
} else {
await sendBatchMessages(validMessages);
}
}
}
export function toggleFuncPanel() {
const panel = document.getElementById('wechat-func-panel');
const expandPanel = document.getElementById('wechat-expand-input');
const emojiPanel = document.getElementById('wechat-emoji-panel');
if (!panel || !expandPanel) return;
if (!expandPanel.classList.contains('hidden')) {
expandPanel.classList.add('hidden');
expandMode = null;
}
// 关闭表情面板
emojiPanel?.classList.add('hidden');
panel.classList.toggle('hidden');
}
export function hideFuncPanel() {
document.getElementById('wechat-func-panel')?.classList.add('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 handleFuncItemClick(func) {
switch (func) {
case 'voice':
hideFuncPanel();
showExpandVoice();
return;
case 'multi':
hideFuncPanel();
showExpandMulti();
return;
case 'photo':
hideFuncPanel();
showExpandPhoto();
return;
case 'voicecall':
hideFuncPanel();
startVoiceCall();
return;
case 'videocall':
hideFuncPanel();
startVideoCall();
return;
case 'music':
hideFuncPanel();
showMusicPanel();
return;
default:
showToast('该功能开发中...', '🧊');
}
}
export function initFuncPanel() {
if (funcPanelInited) return;
const pages = document.getElementById('wechat-func-pages');
if (!pages) return;
funcPanelInited = true;
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', () => {
handleFuncItemClick(item.dataset.func);
});
});
// 初始化音乐面板事件
initMusicEvents();
initMusicShareListener();
}

2699
chat.js Normal file

File diff suppressed because it is too large Load Diff

532
config.js Normal file
View File

@@ -0,0 +1,532 @@
/**
* 配置、常量、默认设<E8AEA4>?
*/
import { extension_settings } from '../../../extensions.js';
// 插件名称
export const extensionName = 'wechat-simulator';
// Meme 表情包列表catbox.moe<6F>?
export const MEME_STICKERS = [
'告到小狗法庭iaordo.jpg',
'小猫伸爪f6nqiq.gif',
'谢谢宝贝我现在那里好<E9878C>?62o48.jpg',
'阿弥陀<E5BCA5>?cwm60.jpg',
'你好美你长得像我爱人hmpkra.jpg',
'我老实了i3ws7s.jpg',
'蹭蹭你贴贴你1of415.gif',
'喜欢你egvwqb.jpg',
'我在哭t343od.jpg',
'不干活就没饭<E6B2A1>?qnrgh.jpg',
'擦眼<E693A6>?gno7e.jpg',
'小狗摇尾巴hmdj2k.gif',
'爱你舔舔你ola7gd.jpg',
'不高兴x6lv1t.jpg',
'大哭3ox1j2.gif',
'你是我老婆8nn1lj.jpg',
'我是你的小狗gnna86.gif',
'我忍ftwaba.jpg',
'别难为狗了gopu17.jpg',
'我会勃起qyyd9g.jpg',
'拘谨扭捏2vejqs.jpg',
'揉揉你qqkv1z.gif',
'狗狗舔小猫vj1714.gif',
'你是我的sj7yzn.jpg',
'要亲亲吗不许拒绝umvaji.jpg',
'震惊害怕muc86m.jpg',
'丑猫哭哭4ybcj1.jpg',
'要哭了tnilep.jpg',
'我来咯r9cix2.gif',
'脑袋空空rbx0ch.jpg',
'跟着你lu2t54.png',
'小熊跳舞122o4w.gif',
'狗鼻子拱拱你kip4fo.gif',
'超级心虚k3xk40.jpg',
'我害怕我走了newaoh.jpg',
'目移69jgvg.jpg',
'上钩了cormmk.jpg',
'无语了我哭了0awxky.jpg',
'你嫌我丢<E68891>?d71mm.jpg',
'笑不出来xkop14.jpg',
'别欺负小狗啊u4t3t3.jpg',
'他妈的真是被看扁了ime5rz.jpg',
'现在强烈地想做爱oqh283.jpg',
'我操klwqm3.jpg',
'这样伤害我不太好吧zihvph.jpg',
'反正我就是变态qgha72.jpg',
'鸡巴梆硬去趟厕所pbxrqh.jpg',
'我哭了你暴力我up99xo.jpg',
'被骂饱了vpixr4.jpg',
'裤裆掏玫瑰l7q8yz.gif',
'傻瓜sbgrcu.jpg',
'咬人5hmtd1.jpg',
'哽咽z38xrc.jpg',
'欸我操了q0fv4d.jpg',
'扭捏9pon3x.jpeg',
'失望eug1e6.jpeg',
'狂犬病发作xb3naz.jpg',
'我是狗吗ma9azs.jpg',
'一笑了<E7AC91>?llb46.jpg',
'装可怜lcglz1.jpg',
'小狗撒欢6j6y6a.gif',
'狗舔舔esw5e2.gif',
'皱眉nibd87.gif',
'大哭auylzr.jpg',
'我要草你5neozi.jpg',
'沉默无言mzyapz.jpg',
'痛哭v4g8v6.jpg',
'擦汗dig3ks.png',
'情欲难抑h1gfp6.jpg',
'扭头不看r8rbzh.jpg',
'神色凄惶wfhp45.jpg',
'哽咽0cmn6h.jpg',
'忍眼泪td0cz7.gif',
'小期待小惊喜335fzr.gif',
'饿了w0cx8k.jpg',
'弱智兔头6svelp.jpg',
'被逮捕了uzeywu.jpg',
'看呆mqnepo.jpg',
'我的理性在远去t9e065.jpg',
'偷亲一<E4BAB2>?jgvb1.gif',
'震惊v5n2ve.jpg',
'爷怒了49r80k.jpg',
'愤怒伤心e7lr3s.jpg',
'狗叫usjdrr.jpg',
'小狗面露难色5bk38l.jpg',
'我投降jkeps1.jpg',
'忍耐中8mnszb.jpg',
'心虚讨好mxtaj7.jpg',
'亲你的手nls3gm.jpg',
'收到ldqwqr.jpg',
'你太可爱我喜欢你ubhai8.jpg',
'惊吓tp9uvd.jpg',
'脸红星星眼dsfs7o.jpg',
'被揍了哭<E4BA86>?1x5zq.jpg',
'嘬嘬fg5gx3.jpg',
'超大声哭<E5A3B0>?86h5v.jpg',
'是的主人yvrgdc.jpg'
];
// Meme 表情包提示词模板
export const MEME_PROMPT_TEMPLATE = `##【必须使用】表情包功能
【重要】你【必须】经常发送表情包每2-3条回复至少发一个表情包<E68385>?
使用规则<EFBFBD>?
- 表情包【必须】单独一条消息<E681AF>?||| 分隔
- 格式<E6A0BC>?meme>文件<E69687>?/meme>
- 只能从下面列表选择,不能编造文件名
可用表情包列表:
[
${MEME_STICKERS.join('\n')}
]
【正确示例】:
好想你|||<meme>小狗摇尾巴hmdj2k.gif</meme>
哈哈哈笑死|||<meme>小熊跳舞122o4w.gif</meme>|||你太搞笑<E6909E>?
<meme>喜欢你egvwqb.jpg</meme>|||我真的好喜欢<E5969C>?
【错误示<EFBFBD>?- 绝对禁止】:
好想<EFBFBD>?meme>xxx</meme> <20>?错误!表情包没有用|||分开
<meme>不存在的表情.jpg</meme> <20>?错误编造了不存在的文件<E69687>?
记住:表情包让聊天更生动,【必须】经常使用!`;
// 默认设置
export const defaultSettings = {
darkMode: true,
/**
* 【自动注入提示词<E7A4BA>?
* 开启后会自动将微信消息格式提示词注入到作者注释中
* 提示词模板见下方 authorNoteTemplate
* 如需自定义格式,修改 authorNoteTemplate 即可
*/
autoInjectPrompt: true,
contacts: [],
phoneVisible: false,
// 记录拖拽后的位置(可选)
phonePosition: null,
userAvatar: '',
wechatId: 'SillyTavern',
// 收藏/世界<E4B896>?用户设定
selectedLorebooks: [],
userPersonas: [],
// ========== 单聊 API 配置 ==========
apiUrl: '',
apiKey: '',
selectedModel: '',
modelList: [],
// ========== 总结功能 API 配置 ==========
summaryApiUrl: '',
summaryApiKey: '',
summarySelectedModel: '',
summaryModelList: [],
// ========== 群聊 API 配置 ==========
// 群聊可以使用独立的API不配置则使用单聊API
groupApiUrl: '',
groupApiKey: '',
groupSelectedModel: '',
groupModelList: [],
// 上下文设<E69687>?
contextEnabled: false,
contextLevel: 5,
contextTags: [],
walletAmount: '5773.89',
/**
* 【哈基米破限<E7A0B4>?
* 针对 Gemini 2.5 Pro 等模型的输出截断问题
* 开启后会在系统提示词头部注入一<E585A5>?创作沙盒"提示<E68F90>?
* 帮助解除模型的自我审查,让输出更完整
*/
hakimiBreakLimit: false,
// 自定义哈基米破限词(留空则使用默认)
hakimiCustomPrompt: '',
// 自定义作者注释模板(留空则使用默认)
authorNoteCustom: '',
// 群聊相关
groupChats: [],
// 错误日志
errorLogs: [],
// 表情用户添加的单个表情<E8A1A8>?
stickers: [],
// 用户表情开<E68385>?
userStickersEnabled: true,
// Meme 表情包功能开<E883BD>?
memeStickersEnabled: false,
/**
* 【群聊提示词注入<E6B3A8>?
* 开启后会将 groupAuthorNote 注入到群聊系统提示词<E7A4BA>?
* 如需自定义群聊格式,修改下方 groupAuthorNote 即可
*/
groupAutoInjectPrompt: true,
groupAuthorNote: `[群聊回复格式指南]
这是一个微信群聊场景你需要扮演群内的角色进行回复<EFBFBD>?
【核心规则<EFBFBD>?
1. 每个角色只能使用自己的专属设定不能使用其他角色的设<E79A84>?
2. 每个角色只扮演自己,不能代替其他角色说话
3. 使用 [角色名]: 内容 的格式回<E5BC8F>?
4. 多个角色回复时<E697B6>?||| 分隔
5. 同一角色可以发送多条消息,也用 ||| 分隔
【消息风格<EFBFBD>?
- 每条消息保持简短自然像真实微信聊天一样1-3句话为宜<E4B8BA>?
- 可以使用表情符号增加表现<E8A1A8>?
- 保持角色性格,让对话有趣生动
- 角色之间可以互动、吐槽、附和、反驳等
【回复要求<EFBFBD>?
- 根据聊天内容自然判断哪些角色会回复不需要所有人都说<E983BD>?
- 语音消息格式:[角色名]: [语音:内容]
- 语音消息必须独立发<E7AB8B>?
示例<EFBFBD>?
[角色A]: 你说得对|||[角色B]: 我不太同意诶|||[角色A]: 为什么啊<E4B988>?
[角色A]: [语音:哈哈哈笑死我了]
[角色B]: @角色A 你是不是傻|||开玩笑的啦`,
userGroupAuthorNote: '', // 用户自定义群聊提示词(界面显示用,留空则使用内置模板)
};
// 作者注释模<E9878A>?
export const authorNoteTemplate = `【可乐不加冰 消息格式指南】每次必须使用以下格式输出消息内容不用生成除此之外的任何其他内容和文本。不得省略任何格式部分<E983A8>?
【核心规<EFBFBD>?- 必须遵守<E981B5>?
- 每条消息都是独立的<E79A84>?||| 分隔多条消息
- 保持消息简短自然像真实微信聊天1-3句话为宜<E4B8BA>?
- 绝对禁止代替{{user}}发言,严禁替{{user}}回复消息,严禁扮演{{user}},严禁捏造输出{{user}}的消<E79A84>?
- 只输出角色的消息内容,禁止添加任何旁白、心理描写或场景说明
【消息数量规<EFBFBD>?- 重要<E9878D>?
- 根据情境动态调整消息数量通常1-7条不<E69DA1>?
- 禁止固定每次回复的消息数<E681AF>?
- 模拟真实聊天节奏
【消息类型格式<EFBFBD>?
- 普通消息直接写内<E58699>?
- 语音消息:[语音:语音内容文字]
- 照片/图片/视频/自拍:[照片:媒体描述]
- 表情包回复:[表情:序号或名称]
- 音乐分享:[音乐:歌名]
- 撤回消息:[撤回]
- 引用回复:[回复:被引用的关键词]回复内容
【多条消息示例<EFBFBD>?
你好|||最近怎么样?
哈哈|||太好笑了|||笑死我了
[语音:好想你啊]|||什么时候有空?
【媒体消息说明】当角色发送图片、视频、自拍等媒体时使用照片格式并提<EFBFBD>?-4句描述
[照片:她随手拍下窗外的晚霞,橙红色的云彩铺满天空]
[照片:一张餐厅自拍,她对着镜头比了个耶的手势,桌上摆着精致的甜点]
[照片:手机截图,显示她正在追的剧刚更新了]
发送媒体的频率应模拟真实聊天习惯不要过于频繁。角色会分享日常随手拍的风景、美食、自拍、截图、录像等<EFBFBD>?
【错误示<EFBFBD>?- 绝对禁止<E7A681>?
*她微微一<E5BEAE>? 你好<E4BDA0>?<3F>?错误禁止添加动作描<E4BD9C>?
你好,最近怎么样?太好笑了 <20>?错误!没有用|||分开
{{user}}: 我也想你 <20>?错误!禁止替用户发言`;
// 世界书名称前缀用于生<E4BA8E>?【可乐】和xx的聊<E79A84>?格式<E6A0BC>?
export const LOREBOOK_NAME_PREFIX = '【可乐】和';
export const LOREBOOK_NAME_SUFFIX = '的聊天';
// 生成世界书名<E4B9A6>?
export function generateLorebookName(contactName) {
return `${LOREBOOK_NAME_PREFIX}${contactName}${LOREBOOK_NAME_SUFFIX}`;
}
// 杯数名称映射
export function getCupName(cupNumber) {
const cupNames = ['第一杯', '第二杯', '第三杯', '第四杯', '第五杯', '第六杯', '第七杯', '第八杯', '第九杯', '第十杯'];
if (cupNumber <= 10) {
return cupNames[cupNumber - 1];
}
return `${cupNumber}`;
}
// 总结标记前缀
export const SUMMARY_MARKER_PREFIX = '🧊 可乐已加冰_';
// 获取设置
export function getSettings() {
if (!extension_settings[extensionName]) loadSettings();
return extension_settings[extensionName];
}
export function getUserStickers(settings = getSettings()) {
const raw = Array.isArray(settings?.stickers) ? settings.stickers : [];
return raw.filter(s => s && typeof s.url === 'string' && s.url.trim());
}
// 解析 <meme> 标签,替换为图片 HTML
export function parseMemeTag(text) {
if (!text || typeof text !== 'string') return text;
// 匹配 <meme>任意描述+文件ID.扩展<E689A9>?/meme>只捕获文件ID部分
// 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符<E7ACA6>?! ? 等的表情名<E68385>?
return text.replace(/<\s*meme\s*>.*?([a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif))\s*<\s*\/\s*meme\s*>/gi, (match, fileId) => {
return `<img src="https://files.catbox.moe/${fileId}" style="max-width:130px; border-radius: 10px; display: block; margin: 0 auto;" alt="表情包" onerror="this.alt='加载失败'; this.style.border='1px dashed #ff4d4f';">`;
});
}
// 检查文本中是否包含 <meme> 标签
export function hasMemeTag(text) {
if (!text || typeof text !== 'string') return false;
return /<meme>\s*.+?\s*<\/meme>/i.test(text);
}
// 智能分割AI消息<EFBC9A>?||| 分隔符,并将 meme/语音/照片/音乐 标签与其他文字分开
export function splitAIMessages(response) {
if (!response || typeof response !== 'string') return [];
// 第一步<E6ADA5>?||| 分隔
const parts = response.split('|||').map(m => m.trim()).filter(m => m);
// 第二步:对每个部分检查是否包含需要分割的特殊标签
const result = [];
// meme 标签 - 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符的表情名称
const memeRegex = /<\s*meme\s*>.*?[a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif)\s*<\s*\/\s*meme\s*>/gi;
// 语音标签 [语音:xxx] 或 [语音xxx]
const voiceRegex = /\[语音[:]\s*.+?\]/g;
// 照片标签 [照片:xxx] 或 [照片xxx]
const photoRegex = /\[照片[:]\s*.+?\]/g;
// 音乐标签:
// 1. [音乐:歌名] 或 [分享音乐:歌名] - 带冒号格式
// 2. [分享音乐] 歌名 - 歌手 - 无冒号格式AI可能会这样输出
const musicRegexWithColon = /\[(?:分享)?音乐[:]\s*.+?\]/g;
const musicRegexNoColon = /\[分享音乐\]\s*[\u4e00-\u9fa5a-zA-Z0-9]+(?:\s*[-–—]\s*[\u4e00-\u9fa5a-zA-Z0-9]+)?/g;
// 表情标签 [表情:xxx]
const stickerRegex = /\[表情[:]\s*.+?\]/g;
// 撤回标签 [撤回] 或 [撤回了一条消息]
const recallRegex = /\[撤回(?:了一条消息)?\]/g;
for (const part of parts) {
// 【重要】检查是否是朋友圈标签 - 朋友圈标签不应该被分割,因为可能包含内嵌的 [照片:xxx]
// 例如:[朋友圈:等着 [照片:自拍照]] 应该作为一个整体
if (/^\[朋友圈[:]/.test(part)) {
result.push(part);
continue;
}
// 收集所有需要分割的标签及其位置
const specialTags = [];
// 查找 meme 标签
let match;
const memeRegexLocal = new RegExp(memeRegex.source, 'gi');
while ((match = memeRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找语音标签
const voiceRegexLocal = new RegExp(voiceRegex.source, 'g');
while ((match = voiceRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找照片标签
const photoRegexLocal = new RegExp(photoRegex.source, 'g');
while ((match = photoRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找音乐标签带冒号格式<E6A0BC>?
const musicRegexLocal1 = new RegExp(musicRegexWithColon.source, 'g');
while ((match = musicRegexLocal1.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找音乐标签无冒号格式<E6A0BC>?
const musicRegexLocal2 = new RegExp(musicRegexNoColon.source, 'g');
while ((match = musicRegexLocal2.exec(part)) !== null) {
// 避免重复匹配(如果已经被带冒号的匹配到)
const alreadyMatched = specialTags.some(t =>
t.index === match.index ||
(match.index >= t.index && match.index < t.index + t.tag.length)
);
if (!alreadyMatched) {
specialTags.push({ tag: match[0], index: match.index });
}
}
// 查找表情标签
const stickerRegexLocal = new RegExp(stickerRegex.source, 'g');
while ((match = stickerRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找撤回标签
const recallRegexLocal = new RegExp(recallRegex.source, 'g');
while ((match = recallRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 如果没有特殊标签直接添<E68EA5>?
if (specialTags.length === 0) {
result.push(part);
continue;
}
// 调试日志
console.log('[可乐] splitAIMessages 分割:', { part, specialTags });
// 按位置排<E7BDAE>?
specialTags.sort((a, b) => a.index - b.index);
// 分割消息
let lastEnd = 0;
for (const { tag, index } of specialTags) {
// 添加标签前的文字
if (index > lastEnd) {
const before = part.substring(lastEnd, index).trim();
if (before) result.push(before);
}
// 添加标签本身
result.push(tag);
lastEnd = index + tag.length;
}
// 添加最后一个标签后的文<E79A84>?
if (lastEnd < part.length) {
const after = part.substring(lastEnd).trim();
if (after) result.push(after);
}
}
// 调试日志
console.log('[可乐] splitAIMessages 结果:', { 原始: response.substring(0, 100), 分割后: result });
return result.filter(m => m);
}
function cloneDefault(value) {
if (Array.isArray(value)) return [...value];
if (value && typeof value === 'object') return { ...value };
return value;
}
function applyDefaults(target, defaults) {
for (const [key, defaultValue] of Object.entries(defaults)) {
if (target[key] === undefined) {
target[key] = cloneDefault(defaultValue);
}
}
}
// 初始化设<E58C96>?
export function loadSettings() {
extension_settings[extensionName] = extension_settings[extensionName] || {};
const settings = extension_settings[extensionName];
applyDefaults(settings, defaultSettings);
// 兼容旧版userPersona -> userPersonas[]
if (settings.userPersona && (!Array.isArray(settings.userPersonas) || settings.userPersonas.length === 0)) {
settings.userPersonas = Array.isArray(settings.userPersonas) ? settings.userPersonas : [];
settings.userPersonas.push({
name: settings.userPersona.name || '用户设定',
content: settings.userPersona.customContent || settings.userPersona.content || '',
enabled: settings.userPersona.enabled !== false,
addedTime: settings.userPersona.addedTime || ''
});
}
if (settings.userPersona) delete settings.userPersona;
// 迁移<EFBC9A>?aiStickers -> stickers“添加的单个表情”
// 说明如果用户已经有自己<E887AA>?stickers则不再合并<E59088>?aiStickers避免把旧默<E697A7>?catbox 列表灌进去<E58EBB>?
const hasUserStickers = Array.isArray(settings.stickers) &&
settings.stickers.some(s => typeof s?.url === 'string' && s.url.trim());
if (Array.isArray(settings.aiStickers)) {
if (!hasUserStickers && settings.aiStickers.length > 0) {
settings.stickers = Array.isArray(settings.stickers) ? settings.stickers : [];
const existingUrls = new Set(
settings.stickers
.map(s => (s?.url || '').toString().trim())
.filter(Boolean)
);
for (const s of settings.aiStickers) {
const url = (s?.url || '').toString().trim();
if (!url || existingUrls.has(url)) continue;
existingUrls.add(url);
settings.stickers.push({
id: s?.id,
url,
name: s?.name || '',
addedTime: s?.addedTime || ''
});
}
}
delete settings.aiStickers;
}
if (!Array.isArray(settings.stickers)) settings.stickers = [];
// 迁移<EFBC9A>?aiStickersEnabled -> userStickersEnabled
if (settings.aiStickersEnabled !== undefined) {
if (settings.userStickersEnabled === undefined) {
settings.userStickersEnabled = settings.aiStickersEnabled;
}
delete settings.aiStickersEnabled;
}
console.log('[可乐] loadSettings 完成:', {
用户表情数量: settings.stickers?.length || 0,
userStickersEnabled: settings.userStickersEnabled !== false
});
}

554
contacts.js Normal file
View File

@@ -0,0 +1,554 @@
/**
* 联系人管理
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js';
import { generateContactsList } from './ui.js';
import { showToast } from './toast.js';
// 当前换头像的联系人索引
let pendingAvatarContactIndex = -1;
// 当前编辑的联系人索引
let currentEditingContactIndex = -1;
// 添加联系人
export function addContact(characterData) {
const settings = getSettings();
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,
// 角色独立配置
useCustomApi: false,
customApiUrl: '',
customApiKey: '',
customModel: '',
customHakimiBreakLimit: false
});
saveSettingsDebounced();
refreshContactsList();
return true;
}
// 刷新联系人列表
export function refreshContactsList() {
const contactsContainer = document.getElementById('wechat-contacts');
if (contactsContainer) {
contactsContainer.innerHTML = generateContactsList();
bindContactsEvents();
}
}
// 删除联系人
export function deleteContact(index) {
const settings = getSettings();
const contact = settings.contacts[index];
if (!contact) return;
if (confirm(`确定要删除 ${contact.name} 吗?`)) {
// 删除关联的世界书(角色卡世界书和总结世界书)
deleteContactLorebooks(contact);
settings.contacts.splice(index, 1);
saveSettingsDebounced();
refreshContactsList();
}
}
// 删除联系人关联的世界书
function deleteContactLorebooks(contact) {
const settings = getSettings();
if (!settings.selectedLorebooks) return;
const contactName = contact.name;
const contactId = contact.id;
// 从后往前遍历删除,避免索引问题
for (let i = settings.selectedLorebooks.length - 1; i >= 0; i--) {
const lb = settings.selectedLorebooks[i];
// 检查是否是该联系人的角色卡世界书
const isCharacterBook = lb.fromCharacter === true &&
(lb.characterName === contactName || lb.characterId === contactId);
// 检查是否是该联系人的总结世界书
const summaryBookName = `${LOREBOOK_NAME_PREFIX}${contactName}${LOREBOOK_NAME_SUFFIX}`;
const isSummaryBook = lb.name === summaryBookName;
if (isCharacterBook || isSummaryBook) {
console.log(`[可乐不加冰] 删除关联世界书: ${lb.name}`);
settings.selectedLorebooks.splice(i, 1);
}
}
}
// 删除群聊
export function deleteGroupChat(groupIndex) {
const settings = getSettings();
const groupChats = settings.groupChats || [];
const group = groupChats[groupIndex];
if (!group) return;
if (confirm(`确定要删除该群聊吗?`)) {
// 删除群聊关联的总结世界书
deleteGroupLorebooks(group, settings);
groupChats.splice(groupIndex, 1);
saveSettingsDebounced();
refreshContactsList();
// 同时刷新聊天列表
import('./ui.js').then(m => m.refreshChatList());
showToast('群聊已删除');
}
}
// 删除群聊关联的世界书
function deleteGroupLorebooks(group, settings) {
if (!settings.selectedLorebooks) return;
// 获取群成员名称列表构建世界书名称
const memberNames = (group.memberIds || []).map(id => {
const contact = settings.contacts?.find(c => c.id === id);
return contact?.name || '未知';
});
const memberNamesStr = memberNames.join(',');
const summaryBookName = `${LOREBOOK_NAME_PREFIX}${memberNamesStr}${LOREBOOK_NAME_SUFFIX}`;
// 从后往前遍历删除,避免索引问题
for (let i = settings.selectedLorebooks.length - 1; i >= 0; i--) {
const lb = settings.selectedLorebooks[i];
if (lb.name === summaryBookName) {
console.log(`[可乐不加冰] 删除群聊关联世界书: ${lb.name}`);
settings.selectedLorebooks.splice(i, 1);
}
}
}
// 更换角色头像(在设置弹窗中使用)
export function changeContactAvatar(contactIndex) {
pendingAvatarContactIndex = contactIndex;
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 = getSettings();
if (settings.contacts[pendingAvatarContactIndex]) {
settings.contacts[pendingAvatarContactIndex].avatar = event.target.result;
saveSettingsDebounced();
refreshContactsList();
// 更新弹窗中的头像预览
updateContactSettingsAvatar(pendingAvatarContactIndex);
showToast('角色头像已更换');
}
};
reader.readAsDataURL(file);
} catch (err) {
console.error('[可乐] 更换角色头像失败:', err);
showToast('更换头像失败: ' + err.message, '❌');
}
e.target.value = '';
});
}
input.click();
}
// 更新弹窗中的头像预览
function updateContactSettingsAvatar(contactIndex) {
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (!contact) return;
const avatarPreview = document.getElementById('wechat-contact-avatar-preview');
if (avatarPreview) {
const firstChar = contact.name ? contact.name.charAt(0) : '?';
avatarPreview.innerHTML = contact.avatar
? `<img src="${contact.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`
: firstChar;
}
}
// 打开角色设置弹窗
export function openContactSettings(contactIndex) {
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (!contact) return;
currentEditingContactIndex = contactIndex;
// 填充头像和名称
const avatarPreview = document.getElementById('wechat-contact-avatar-preview');
const nameEl = document.getElementById('wechat-contact-settings-name');
if (avatarPreview) {
const firstChar = contact.name ? contact.name.charAt(0) : '?';
avatarPreview.innerHTML = contact.avatar
? `<img src="${contact.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`
: firstChar;
}
if (nameEl) nameEl.textContent = contact.name;
// 填充独立 API 配置
const useCustomApi = contact.useCustomApi || false;
const customApiToggle = document.getElementById('wechat-contact-custom-api-toggle');
const apiSettingsDiv = document.getElementById('wechat-contact-api-settings');
if (customApiToggle) {
customApiToggle.classList.toggle('on', useCustomApi);
}
if (apiSettingsDiv) {
if (useCustomApi) {
apiSettingsDiv.classList.remove('hidden');
apiSettingsDiv.style.display = 'flex';
} else {
apiSettingsDiv.classList.add('hidden');
apiSettingsDiv.style.display = 'none';
}
}
document.getElementById('wechat-contact-api-url').value = contact.customApiUrl || '';
document.getElementById('wechat-contact-api-key').value = contact.customApiKey || '';
document.getElementById('wechat-contact-model').value = contact.customModel || '';
// 填充哈基米破限
const hakimiToggle = document.getElementById('wechat-contact-hakimi-toggle');
if (hakimiToggle) {
hakimiToggle.classList.toggle('on', contact.customHakimiBreakLimit || false);
}
// 显示弹窗
document.getElementById('wechat-contact-settings-modal')?.classList.remove('hidden');
}
// 保存角色设置
export function saveContactSettings() {
if (currentEditingContactIndex < 0) return;
const settings = getSettings();
const contact = settings.contacts[currentEditingContactIndex];
if (!contact) return;
// 保存独立 API 配置
contact.useCustomApi = document.getElementById('wechat-contact-custom-api-toggle')?.classList.contains('on') || false;
contact.customApiUrl = document.getElementById('wechat-contact-api-url')?.value?.trim() || '';
contact.customApiKey = document.getElementById('wechat-contact-api-key')?.value?.trim() || '';
contact.customModel = document.getElementById('wechat-contact-model')?.value?.trim() || '';
// 保存哈基米破限
contact.customHakimiBreakLimit = document.getElementById('wechat-contact-hakimi-toggle')?.classList.contains('on') || false;
saveSettingsDebounced();
showToast('角色设置已保存');
// 关闭弹窗
document.getElementById('wechat-contact-settings-modal')?.classList.add('hidden');
currentEditingContactIndex = -1;
}
// 关闭角色设置弹窗
export function closeContactSettings() {
document.getElementById('wechat-contact-settings-modal')?.classList.add('hidden');
currentEditingContactIndex = -1;
}
// 获取当前编辑的联系人索引
export function getCurrentEditingContactIndex() {
return currentEditingContactIndex;
}
// 绑定联系人事件
export function bindContactsEvents() {
// 导入 openChat 以避免循环依赖
import('./chat.js').then(chatModule => {
// 单击卡片进入聊天
document.querySelectorAll('.wechat-contact-card:not(.wechat-group-card) .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);
chatModule.openChat(index);
});
});
});
// 群聊卡片点击进入群聊
import('./group-chat.js').then(groupModule => {
document.querySelectorAll('.wechat-group-card .wechat-card-content').forEach(card => {
card.addEventListener('click', function(e) {
const cardEl = this.closest('.wechat-group-card');
const groupIndex = parseInt(cardEl.dataset.groupIndex);
groupModule.openGroupChat(groupIndex);
});
});
// 群聊头像点击也进入群聊
document.querySelectorAll('.wechat-group-avatar').forEach(avatar => {
avatar.addEventListener('click', function(e) {
e.stopPropagation();
const groupIndex = parseInt(this.dataset.groupIndex);
groupModule.openGroupChat(groupIndex);
});
});
});
// 群聊删除按钮
document.querySelectorAll('.wechat-group-delete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const groupIndex = parseInt(this.dataset.groupIndex);
deleteGroupChat(groupIndex);
});
});
// 头像事件绑定(长按删除 + 单击打开设置)
document.querySelectorAll('.wechat-card-avatar').forEach(avatar => {
let pressTimer = null;
let isLongPress = false;
// 长按开始
const handlePressStart = (e) => {
isLongPress = false;
pressTimer = setTimeout(() => {
isLongPress = true;
showDeleteBubble(avatar);
}, 500);
};
// 长按取消
const handlePressEnd = (e) => {
clearTimeout(pressTimer);
// 如果不是长按,则执行单击打开设置弹窗
if (!isLongPress) {
const index = parseInt(avatar.dataset.index);
openContactSettings(index);
}
};
// 移动时取消长按
const handlePressCancel = () => {
clearTimeout(pressTimer);
};
// 触摸设备
avatar.addEventListener('touchstart', handlePressStart, { passive: true });
avatar.addEventListener('touchend', handlePressEnd);
avatar.addEventListener('touchmove', handlePressCancel, { passive: true });
avatar.addEventListener('touchcancel', handlePressCancel);
// 鼠标设备
avatar.addEventListener('mousedown', handlePressStart);
avatar.addEventListener('mouseup', handlePressEnd);
avatar.addEventListener('mouseleave', handlePressCancel);
// 阻止原有的click事件
avatar.addEventListener('click', (e) => {
e.stopPropagation();
});
});
// 删除按钮点击
document.querySelectorAll('.wechat-card-delete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const index = parseInt(this.dataset.index);
deleteContact(index);
});
});
// 点击其他地方关闭删除气泡
document.addEventListener('click', hideDeleteBubble);
document.addEventListener('touchstart', hideDeleteBubble, { passive: true });
// 初始化滑动删除功能
initSwipeToDelete();
}
// 显示删除气泡
function showDeleteBubble(avatarEl) {
// 先移除已有的气泡
hideDeleteBubble();
const index = parseInt(avatarEl.dataset.index);
const settings = getSettings();
const contact = settings.contacts[index];
if (!contact) return;
// 创建删除气泡
const bubble = document.createElement('div');
bubble.className = 'wechat-delete-bubble';
bubble.dataset.index = index;
bubble.innerHTML = `<span>🗑️</span> 删除`;
// 添加到头像元素
avatarEl.style.position = 'relative';
avatarEl.classList.add('has-bubble');
avatarEl.appendChild(bubble);
// 绑定删除事件
bubble.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(bubble.dataset.index);
deleteContactDirect(idx);
hideDeleteBubble();
});
// 触摸设备
bubble.addEventListener('touchend', (e) => {
e.stopPropagation();
e.preventDefault();
const idx = parseInt(bubble.dataset.index);
deleteContactDirect(idx);
hideDeleteBubble();
});
}
// 隐藏删除气泡
function hideDeleteBubble(e) {
// 如果点击的是气泡本身,不关闭
if (e && e.target.closest('.wechat-delete-bubble')) return;
const bubbles = document.querySelectorAll('.wechat-delete-bubble');
bubbles.forEach(bubble => bubble.remove());
document.querySelectorAll('.wechat-card-avatar.has-bubble').forEach(avatar => {
avatar.classList.remove('has-bubble');
});
}
// 直接删除联系人(不需要确认)
function deleteContactDirect(index) {
const settings = getSettings();
const contact = settings.contacts[index];
if (!contact) return;
// 删除关联的世界书(角色卡世界书和总结世界书)
deleteContactLorebooks(contact);
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';
const isGroupCard = card.classList.contains('wechat-group-card');
let startX = 0;
let currentX = 0;
let isDragging = false;
let hasMoved = false; // 是否真的发生了移动
let isOpen = false;
const deleteWidth = 70;
const moveThreshold = 10; // 移动阈值,超过此距离才算拖动
const handleStart = (e) => {
// 群聊卡片不需要跳过头像
if (!isGroupCard && e.target.closest('.wechat-card-avatar')) return;
isDragging = true;
hasMoved = false;
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;
// 只有移动超过阈值才算真正的拖动
if (Math.abs(diff) > moveThreshold) {
hasMoved = true;
}
if (!hasMoved) return;
let newX = isOpen ? -deleteWidth + diff : 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 (!hasMoved) {
return;
}
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 = (e) => {
handleEnd();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
wrapper.addEventListener('mousedown', (e) => {
if (!isGroupCard && e.target.closest('.wechat-card-avatar')) return;
closeOthers();
handleStart(e);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
// 不再 preventDefault让点击事件可以正常触发
});
});
}

402
emoji-panel.js Normal file
View File

@@ -0,0 +1,402 @@
/**
* 表情面板功能
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings } from './config.js';
import { showToast } from './toast.js';
import { isInGroupChat } from './group-chat.js';
import { hasPendingStickerSelection, setStickerForMultiMsg } from './chat-func-panel.js';
let emojiPanelInited = false;
// 默认表情包列表catbox 图床)
const DEFAULT_STICKERS = [
{ id: 'iaordo', ext: 'jpg', name: '告到小狗法庭' },
{ id: 'f6nqiq', ext: 'gif', name: '小猫伸爪' },
{ id: '862o48', ext: 'jpg', name: '谢谢宝贝我现在那里好硬' },
{ id: '9cwm60', ext: 'jpg', name: '阿弥陀佛' },
{ id: 'hmpkra', ext: 'jpg', name: '你好美你长得像我爱人' },
{ id: 'i3ws7s', ext: 'jpg', name: '我老实了' },
{ id: '1of415', ext: 'gif', name: '蹭蹭你贴贴你' },
{ id: 'egvwqb', ext: 'jpg', name: '喜欢你' },
{ id: 't343od', ext: 'jpg', name: '我在哭' },
{ id: '2qnrgh', ext: 'jpg', name: '不干活就没饭吃' },
{ id: '9gno7e', ext: 'jpg', name: '擦眼泪' },
{ id: 'hmdj2k', ext: 'gif', name: '小狗摇尾巴' },
{ id: 'ola7gd', ext: 'jpg', name: '爱你舔舔你' },
{ id: 'x6lv1t', ext: 'jpg', name: '不高兴' },
{ id: '3ox1j2', ext: 'gif', name: '大哭' },
{ id: '8nn1lj', ext: 'jpg', name: '你是我老婆' },
{ id: 'gnna86', ext: 'gif', name: '我是你的小狗' },
{ id: 'ftwaba', ext: 'jpg', name: '我忍' },
{ id: 'gopu17', ext: 'jpg', name: '别难为狗了' },
{ id: 'qyyd9g', ext: 'jpg', name: '我会勃起' },
{ id: '2vejqs', ext: 'jpg', name: '拘谨扭捏' },
{ id: 'qqkv1z', ext: 'gif', name: '揉揉你' },
{ id: 'vj1714', ext: 'gif', name: '狗狗舔小猫' },
{ id: 'sj7yzn', ext: 'jpg', name: '你是我的' },
{ id: 'umvaji', ext: 'jpg', name: '要亲亲吗不许拒绝' },
{ id: 'muc86m', ext: 'jpg', name: '震惊害怕' },
{ id: '4ybcj1', ext: 'jpg', name: '丑猫哭哭' },
{ id: 'tnilep', ext: 'jpg', name: '要哭了' },
{ id: 'r9cix2', ext: 'gif', name: '我来咯' },
{ id: 'rbx0ch', ext: 'jpg', name: '脑袋空空' },
{ id: 'lu2t54', ext: 'png', name: '跟着你' },
{ id: '122o4w', ext: 'gif', name: '小熊跳舞' },
{ id: 'kip4fo', ext: 'gif', name: '狗鼻子拱拱你' },
{ id: 'k3xk40', ext: 'jpg', name: '超级心虚' },
{ id: 'newaoh', ext: 'jpg', name: '我害怕我走了' },
{ id: '69jgvg', ext: 'jpg', name: '目移' },
{ id: 'cormmk', ext: 'jpg', name: '上钩了' },
{ id: '0awxky', ext: 'jpg', name: '无语了我哭了' },
{ id: '8d71mm', ext: 'jpg', name: '你嫌我丢人' },
{ id: 'xkop14', ext: 'jpg', name: '笑不出来' },
{ id: 'u4t3t3', ext: 'jpg', name: '别欺负小狗啊' },
{ id: 'ime5rz', ext: 'jpg', name: '他妈的真是被看扁了' },
{ id: 'oqh283', ext: 'jpg', name: '现在强烈地想做爱' },
{ id: 'klwqm3', ext: 'jpg', name: '我操' },
{ id: 'zihvph', ext: 'jpg', name: '这样伤害我不太好吧' },
{ id: 'qgha72', ext: 'jpg', name: '反正我就是变态' },
{ id: 'pbxrqh', ext: 'jpg', name: '鸡巴梆硬去趟厕所' },
{ id: 'up99xo', ext: 'jpg', name: '我哭了你暴力我' },
{ id: 'vpixr4', ext: 'jpg', name: '被骂饱了' },
{ id: 'l7q8yz', ext: 'gif', name: '裤裆掏玫瑰' },
{ id: 'sbgrcu', ext: 'jpg', name: '傻瓜' },
{ id: '5hmtd1', ext: 'jpg', name: '咬人' },
{ id: 'z38xrc', ext: 'jpg', name: '哽咽' },
{ id: 'q0fv4d', ext: 'jpg', name: '欸我操了' },
{ id: '9pon3x', ext: 'jpeg', name: '扭捏' },
{ id: 'eug1e6', ext: 'jpeg', name: '失望' },
{ id: 'xb3naz', ext: 'jpg', name: '狂犬病发作' },
{ id: 'ma9azs', ext: 'jpg', name: '我是狗吗' },
{ id: '9llb46', ext: 'jpg', name: '一笑了之' },
{ id: 'lcglz1', ext: 'jpg', name: '装可怜' },
{ id: '6j6y6a', ext: 'gif', name: '小狗撒欢' },
{ id: 'esw5e2', ext: 'gif', name: '狗舔舔' },
{ id: 'nibd87', ext: 'gif', name: '皱眉' },
{ id: 'auylzr', ext: 'jpg', name: '大哭2' },
{ id: '5neozi', ext: 'jpg', name: '我要草你' },
{ id: 'mzyapz', ext: 'jpg', name: '沉默无言' },
{ id: 'v4g8v6', ext: 'jpg', name: '痛哭' },
{ id: 'dig3ks', ext: 'png', name: '擦汗' },
{ id: 'h1gfp6', ext: 'jpg', name: '情欲难抑' },
{ id: 'r8rbzh', ext: 'jpg', name: '扭头不看' },
{ id: 'wfhp45', ext: 'jpg', name: '神色凄惶' },
{ id: '0cmn6h', ext: 'jpg', name: '哽咽2' },
{ id: 'td0cz7', ext: 'gif', name: '忍眼泪' },
{ id: '335fzr', ext: 'gif', name: '小期待小惊喜' },
{ id: 'w0cx8k', ext: 'jpg', name: '饿了' },
{ id: '6svelp', ext: 'jpg', name: '弱智兔头' },
{ id: 'uzeywu', ext: 'jpg', name: '被逮捕了' },
{ id: 'mqnepo', ext: 'jpg', name: '看呆' },
{ id: 't9e065', ext: 'jpg', name: '我的理性在远去' },
{ id: '1jgvb1', ext: 'gif', name: '偷亲一口' },
{ id: 'v5n2ve', ext: 'jpg', name: '震惊' },
{ id: '49r80k', ext: 'jpg', name: '爷怒了' },
{ id: 'e7lr3s', ext: 'jpg', name: '愤怒伤心' },
{ id: 'usjdrr', ext: 'jpg', name: '狗叫' },
{ id: '5bk38l', ext: 'jpg', name: '小狗面露难色' },
{ id: 'jkeps1', ext: 'jpg', name: '我投降' },
{ id: '8mnszb', ext: 'jpg', name: '忍耐中' },
{ id: 'mxtaj7', ext: 'jpg', name: '心虚讨好' },
{ id: 'nls3gm', ext: 'jpg', name: '亲你的手' },
{ id: 'ldqwqr', ext: 'jpg', name: '收到' },
{ id: 'ubhai8', ext: 'jpg', name: '你太可爱我喜欢你' },
{ id: 'tp9uvd', ext: 'jpg', name: '惊吓' },
{ id: 'dsfs7o', ext: 'jpg', name: '脸红星星眼' },
{ id: '81x5zq', ext: 'jpg', name: '被揍了哭哭' },
{ id: 'fg5gx3', ext: 'jpg', name: '嘬嘬' },
{ id: '186h5v', ext: 'jpg', name: '超大声哭哭' },
{ id: 'yvrgdc', ext: 'jpg', name: '是的主人' },
{ id: '2wmca0', ext: 'jpg', name: '勃起了' },
{ id: 'ao8b5b', ext: 'jpg', name: '我恨上学' },
{ id: 'cpun5d', ext: 'jpg', name: '灰溜溜离开' },
];
// 获取 catbox URL
function getCatboxUrl(id, ext) {
return `https://files.catbox.moe/${id}.${ext}`;
}
// 切换表情面板显示/隐藏
export function toggleEmojiPanel() {
const panel = document.getElementById('wechat-emoji-panel');
const funcPanel = document.getElementById('wechat-func-panel');
const expandPanel = document.getElementById('wechat-expand-input');
if (!panel) return;
// 关闭其他面板
funcPanel?.classList.add('hidden');
expandPanel?.classList.add('hidden');
// 切换表情面板
const isHidden = panel.classList.contains('hidden');
panel.classList.toggle('hidden');
// 如果打开面板,刷新表情列表
if (isHidden) {
refreshEmojiGrid();
}
}
// 隐藏表情面板
export function hideEmojiPanel() {
document.getElementById('wechat-emoji-panel')?.classList.add('hidden');
}
// 刷新表情网格
export function refreshEmojiGrid() {
const content = document.getElementById('wechat-emoji-content');
if (!content) return;
let html = '';
// 默认表情区域
html += '<div class="wechat-emoji-section-title">默认表情</div>';
html += '<div class="wechat-emoji-grid" id="wechat-emoji-default-grid">';
html += `<button class="wechat-emoji-add" id="wechat-emoji-add-btn">+</button>`;
DEFAULT_STICKERS.forEach((sticker, index) => {
const url = getCatboxUrl(sticker.id, sticker.ext);
html += `
<div class="wechat-emoji-item wechat-emoji-default-item" data-default-index="${index}" title="${sticker.name}">
<img src="${url}" alt="${sticker.name}" loading="lazy">
</div>
`;
});
html += '</div>';
content.innerHTML = html;
// 绑定添加按钮事件
document.getElementById('wechat-emoji-add-btn')?.addEventListener('click', showAddStickerDialog);
// 绑定默认表情点击事件
content.querySelectorAll('.wechat-emoji-default-item').forEach(item => {
item.addEventListener('click', () => {
const index = parseInt(item.dataset.defaultIndex);
sendDefaultSticker(index);
});
});
}
// 显示添加表情对话框
function showAddStickerDialog() {
const choice = prompt(
'添加表情方式:\n' +
'1. 输入 catbox 文件名被揍了哭哭81x5zq.jpg\n' +
'2. 直接输入图片URL\n' +
'3. 输入 "file" 从本地选择图片\n\n' +
'支持一次添加多个,用换行或逗号分隔:'
);
if (!choice) return;
if (choice.trim().toLowerCase() === 'file') {
addStickerFromFile();
return;
}
// 解析输入,支持多个
const inputs = choice.split(/[,\n]/).map(s => s.trim()).filter(s => s);
addStickersFromInput(inputs);
}
// 从输入添加表情
function addStickersFromInput(inputs) {
const settings = getSettings();
if (!Array.isArray(settings.stickers)) {
settings.stickers = [];
}
let addedCount = 0;
for (const input of inputs) {
let url = '';
let name = input;
// 检查是否是完整 URL
if (input.startsWith('http://') || input.startsWith('https://')) {
url = input;
name = input.split('/').pop() || input;
} else {
// 尝试解析 catbox 格式:名称+ID.扩展名
const match = input.match(/^(.+?)([a-z0-9]{6})\.(jpg|jpeg|png|gif|webp)$/i);
if (match) {
const [, stickerName, id, ext] = match;
url = getCatboxUrl(id, ext);
name = stickerName || input;
} else {
// 尝试只有 ID.扩展名 的格式
const simpleMatch = input.match(/^([a-z0-9]{6})\.(jpg|jpeg|png|gif|webp)$/i);
if (simpleMatch) {
const [, id, ext] = simpleMatch;
url = getCatboxUrl(id, ext);
name = input;
} else {
showToast(`无法解析: ${input}`, '⚠️');
continue;
}
}
}
// 检查是否已存在
const exists = settings.stickers.some(s => s.url === url);
if (exists) {
showToast(`已存在: ${name}`, '🧊');
continue;
}
// 调试:显示添加的表情信息
console.log('[可乐] 添加表情:', { name, url });
settings.stickers.push({
url,
name,
addedTime: new Date().toISOString()
});
addedCount++;
}
if (addedCount > 0) {
saveSettingsDebounced();
refreshEmojiGrid();
showToast(`已添加 ${addedCount} 个表情`);
}
}
// 从本地文件添加表情
function addStickerFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.addEventListener('change', async (e) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
const settings = getSettings();
if (!Array.isArray(settings.stickers)) {
settings.stickers = [];
}
let addedCount = 0;
for (const file of files) {
try {
const dataUrl = await readFileAsDataURL(file);
settings.stickers.push({
url: dataUrl,
name: file.name,
addedTime: new Date().toISOString()
});
addedCount++;
} catch (err) {
console.error('[可乐] 添加表情失败:', err);
}
}
if (addedCount > 0) {
saveSettingsDebounced();
refreshEmojiGrid();
showToast(`已添加 ${addedCount} 个表情`);
}
});
input.click();
}
// 读取文件为 DataURL
function readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 发送用户表情
function sendUserSticker(index) {
const settings = getSettings();
const stickers = settings.stickers || [];
const sticker = stickers[index];
if (!sticker) return;
hideEmojiPanel();
sendStickerUrl(sticker.url, sticker.name || '');
}
// 发送默认表情
function sendDefaultSticker(index) {
const sticker = DEFAULT_STICKERS[index];
if (!sticker) return;
hideEmojiPanel();
const url = getCatboxUrl(sticker.id, sticker.ext);
sendStickerUrl(url, sticker.name || '');
}
// 发送表情 URL
function sendStickerUrl(url, description = '') {
// 检查是否是为混合消息选择表情
if (hasPendingStickerSelection()) {
setStickerForMultiMsg(url);
return;
}
// 正常发送表情消息
if (isInGroupChat()) {
import('./group-chat.js').then(m => {
m.sendGroupStickerMessage(url, description);
});
} else {
import('./chat.js').then(m => {
m.sendStickerMessage(url, description);
});
}
}
// 删除用户表情
function deleteSticker(index) {
if (!confirm('确定要删除这个表情吗?')) return;
const settings = getSettings();
const stickers = settings.stickers || [];
if (index >= 0 && index < stickers.length) {
stickers.splice(index, 1);
saveSettingsDebounced();
refreshEmojiGrid();
showToast('表情已删除');
}
}
// 初始化表情面板
export function initEmojiPanel() {
if (emojiPanelInited) return;
const panel = document.getElementById('wechat-emoji-panel');
if (!panel) return;
emojiPanelInited = true;
// 绑定标签切换事件
document.querySelectorAll('.wechat-emoji-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.wechat-emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.dataset.tab;
if (tabName === 'search') {
showToast('搜索功能开发中...', '🧊');
}
});
});
// 初始刷新表情网格
refreshEmojiGrid();
}

1228
favorites.js Normal file

File diff suppressed because it is too large Load Diff

2812
group-chat.js Normal file

File diff suppressed because it is too large Load Diff

313
history-logs.js Normal file
View File

@@ -0,0 +1,313 @@
/**
* 历史回顾和日志功能
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js';
import { escapeHtml } from './utils.js';
import { showToast } from './toast.js';
// 最大日志数量
const MAX_LOGS = 20;
// 获取错误日志
export function getErrorLogs() {
const settings = getSettings();
return settings.errorLogs || [];
}
// 添加错误日志
export function addErrorLog(error, context = '') {
const settings = getSettings();
if (!settings.errorLogs) settings.errorLogs = [];
const now = new Date();
const timeStr = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0') + ':' + now.getSeconds().toString().padStart(2,'0');
// 生成简短的错误摘要约15字
const errorMsg = error?.message || String(error);
let summary = context ? context + ': ' : '';
// 截取关键信息
if (errorMsg.length > 15 - summary.length) {
summary += errorMsg.substring(0, 15 - summary.length) + '...';
} else {
summary += errorMsg;
}
const logEntry = {
time: timeStr,
summary: summary.substring(0, 18), // 确保不超过18字
message: errorMsg,
context: context
};
settings.errorLogs.unshift(logEntry);
// 只保留最近的 MAX_LOGS 条
if (settings.errorLogs.length > MAX_LOGS) {
settings.errorLogs = settings.errorLogs.slice(0, MAX_LOGS);
}
saveSettingsDebounced();
return logEntry;
}
// 清空错误日志
export function clearErrorLogs() {
const settings = getSettings();
settings.errorLogs = [];
saveSettingsDebounced();
}
// 刷新日志列表显示
export function refreshLogsList() {
const listEl = document.getElementById('wechat-logs-list');
if (!listEl) return;
const logs = getErrorLogs();
if (logs.length === 0) {
listEl.innerHTML = '<div style="text-align: center; color: var(--wechat-text-secondary); padding: 20px;">暂无错误日志 ✅</div>';
return;
}
listEl.innerHTML = logs.map((log, idx) => `
<div class="wechat-log-item" style="padding: 8px 10px; border-bottom: 1px solid var(--wechat-border); ${idx === 0 ? 'background: rgba(255,77,79,0.08);' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #ff4d4f; font-weight: 500;">${escapeHtml(log.summary || log.message?.substring(0, 15) + '...')}</span>
<span style="color: var(--wechat-text-secondary); font-size: 11px;">${escapeHtml(log.time)}</span>
</div>
${log.message && log.message !== log.summary ? `<div style="margin-top: 4px; font-size: 11px; color: var(--wechat-text-secondary); word-break: break-all;">${escapeHtml(log.message.substring(0, 80))}${log.message.length > 80 ? '...' : ''}</div>` : ''}
</div>
`).join('');
}
// 判断世界书是否是总结生成的
function isSummaryLorebook(lorebook) {
// 检查名称格式【可乐】和xxx的聊天
if (lorebook.name?.startsWith(LOREBOOK_NAME_PREFIX) && lorebook.name?.endsWith(LOREBOOK_NAME_SUFFIX)) {
return true;
}
// 检查标记
if (lorebook.fromSummary === true) {
return true;
}
return false;
}
// 判断是否是群聊总结
function isGroupSummary(lorebook) {
// 从名称中提取人名部分
if (!lorebook.name?.startsWith(LOREBOOK_NAME_PREFIX)) return false;
const nameContent = lorebook.name.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
// 如果包含逗号,说明是多人(群聊)
return nameContent.includes(',') || nameContent.includes('');
}
// 获取总结世界书列表(按类型分类)
export function getSummaryLorebooks(filter = 'all') {
const settings = getSettings();
const selectedLorebooks = settings.selectedLorebooks || [];
const summaryBooks = selectedLorebooks
.map((lb, idx) => ({ ...lb, originalIndex: idx }))
.filter(lb => isSummaryLorebook(lb));
if (filter === 'all') {
return summaryBooks;
} else if (filter === 'contact') {
return summaryBooks.filter(lb => !isGroupSummary(lb));
} else if (filter === 'group') {
return summaryBooks.filter(lb => isGroupSummary(lb));
}
return summaryBooks;
}
// 刷新历史回顾列表
export function refreshHistoryList(filter = 'all') {
const listEl = document.getElementById('wechat-history-list');
if (!listEl) return;
const summaryBooks = getSummaryLorebooks(filter);
if (summaryBooks.length === 0) {
const emptyText = filter === 'contact' ? '暂无单聊总结' : filter === 'group' ? '暂无群聊总结' : '暂无总结记录';
listEl.innerHTML = `<div style="text-align: center; color: var(--wechat-text-secondary); padding: 30px;">${emptyText}<br><span style="font-size: 12px;">前往"总结"功能生成总结</span></div>`;
return;
}
listEl.innerHTML = summaryBooks.map(lb => {
// 从名称中提取人名
let displayName = lb.name;
if (lb.name?.startsWith(LOREBOOK_NAME_PREFIX)) {
displayName = lb.name.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
const isGroup = isGroupSummary(lb);
const entriesCount = lb.entries?.length || 0;
return `
<div class="wechat-history-item" data-index="${lb.originalIndex}" style="padding: 12px; border-bottom: 1px solid var(--wechat-border); cursor: pointer;">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #000;">${escapeHtml(displayName)}</div>
<div style="font-size: 12px; color: #000;">${entriesCount} 杯总结 · ${lb.lastUpdated || lb.addedTime || '未知时间'}</div>
</div>
<label class="wechat-toggle wechat-toggle-small" onclick="event.stopPropagation()">
<input type="checkbox" class="wechat-history-toggle" data-index="${lb.originalIndex}" ${lb.enabled !== false ? 'checked' : ''}>
<span class="wechat-toggle-slider"></span>
</label>
</div>
</div>
`;
}).join('');
// 绑定点击事件
listEl.querySelectorAll('.wechat-history-item').forEach(item => {
item.addEventListener('click', () => {
const idx = parseInt(item.dataset.index);
showHistoryDetail(idx);
});
});
// 绑定开关事件
listEl.querySelectorAll('.wechat-history-toggle').forEach(toggle => {
toggle.addEventListener('change', (e) => {
e.stopPropagation();
const idx = parseInt(toggle.dataset.index);
toggleHistoryItem(idx, toggle.checked);
});
});
}
// 切换历史记录项启用状态
export function toggleHistoryItem(index, enabled) {
const settings = getSettings();
if (settings.selectedLorebooks?.[index]) {
settings.selectedLorebooks[index].enabled = enabled;
saveSettingsDebounced();
showToast(enabled ? '已启用' : '已禁用');
}
}
// 显示历史记录详情
export function showHistoryDetail(index) {
const settings = getSettings();
const lorebook = settings.selectedLorebooks?.[index];
if (!lorebook) return;
// 从名称中提取人名
let displayName = lorebook.name;
if (lorebook.name?.startsWith(LOREBOOK_NAME_PREFIX)) {
displayName = lorebook.name.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
const entries = lorebook.entries || [];
// 创建详情弹窗
const modal = document.createElement('div');
modal.className = 'wechat-modal';
modal.id = 'wechat-history-detail-modal';
modal.innerHTML = `
<div class="wechat-modal-content" style="position: relative; max-width: 400px; max-height: 80vh; overflow-y: auto;">
<button class="wechat-modal-close-x" id="wechat-history-detail-close">×</button>
<div class="wechat-modal-title">${escapeHtml(displayName)}</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 12px;">
${isGroupSummary(lorebook) ? '👥 群聊总结' : '💬 单聊总结'} · ${entries.length}
</div>
<div class="wechat-history-entries" style="max-height: 400px; overflow-y: auto;">
${entries.length === 0 ? '<div style="text-align: center; color: var(--wechat-text-secondary); padding: 20px;">暂无条目</div>' :
entries.map((entry, idx) => `
<div class="wechat-history-entry" data-entry-index="${idx}" style="padding: 12px; border: 1px solid var(--wechat-border); border-radius: 8px; margin-bottom: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-weight: 500;">${escapeHtml(entry.comment || '第' + (idx + 1) + '杯')}</span>
<label class="wechat-toggle wechat-toggle-small">
<input type="checkbox" class="wechat-entry-toggle" data-entry-index="${idx}" ${entry.enabled !== false ? 'checked' : ''}>
<span class="wechat-toggle-slider"></span>
</label>
</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 6px;">
${(entry.keys || []).map(k => `<span style="background: var(--wechat-bg-secondary); padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${escapeHtml(k)}</span>`).join('')}
</div>
<div style="font-size: 13px; line-height: 1.5; color: var(--wechat-text-primary);">${escapeHtml(entry.content || '').substring(0, 200)}${(entry.content?.length || 0) > 200 ? '...' : ''}</div>
</div>
`).join('')
}
</div>
<div class="wechat-modal-actions" style="margin-top: 12px; display: flex; gap: 8px;">
<button class="wechat-btn wechat-btn-primary wechat-btn-small" id="wechat-history-sync" style="flex: 1;">同步到酒馆</button>
<button class="wechat-btn wechat-btn-small" id="wechat-history-refresh" style="flex: 1;">从酒馆刷新</button>
</div>
</div>
`;
document.body.appendChild(modal);
// 关闭按钮
modal.querySelector('#wechat-history-detail-close').addEventListener('click', () => modal.remove());
// 点击背景关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
// 条目开关
modal.querySelectorAll('.wechat-entry-toggle').forEach(toggle => {
toggle.addEventListener('change', (e) => {
const entryIdx = parseInt(toggle.dataset.entryIndex);
if (settings.selectedLorebooks?.[index]?.entries?.[entryIdx]) {
settings.selectedLorebooks[index].entries[entryIdx].enabled = toggle.checked;
saveSettingsDebounced();
}
});
});
// 同步到酒馆按钮
modal.querySelector('#wechat-history-sync').addEventListener('click', async () => {
const btn = modal.querySelector('#wechat-history-sync');
btn.disabled = true;
btn.textContent = '同步中...';
try {
const { syncEntryToSillyTavern } = await import('./summary.js');
for (let i = 0; i < entries.length; i++) {
await syncEntryToSillyTavern(entries[i], i + 1, lorebook.name);
}
showToast('已同步到酒馆');
} catch (err) {
console.error('[可乐] 同步失败:', err);
showToast('同步失败: ' + err.message, '⚠️');
addErrorLog(err, '历史回顾同步');
} finally {
btn.disabled = false;
btn.textContent = '同步到酒馆';
}
});
// 从酒馆刷新按钮
modal.querySelector('#wechat-history-refresh').addEventListener('click', async () => {
const btn = modal.querySelector('#wechat-history-refresh');
btn.disabled = true;
btn.textContent = '刷新中...';
try {
const { refreshLorebookFromTavern } = await import('./favorites.js');
await refreshLorebookFromTavern(lorebook.name, index);
showToast('已从酒馆刷新');
modal.remove();
refreshHistoryList();
} catch (err) {
console.error('[可乐] 从酒馆刷新失败:', err);
showToast('刷新失败: ' + err.message, '⚠️');
addErrorLog(err, '历史回顾刷新');
} finally {
btn.disabled = false;
btn.textContent = '从酒馆刷新';
}
});
}
// 初始化错误捕获(仅捕获插件内部错误)
export function initErrorCapture() {
// 插件错误由各模块调用 addErrorLog 主动记录
// 不再全局捕获 console.error避免记录酒馆其他错误
console.log('[可乐不加冰] 错误日志系统已初始化');
}

6
index.js Normal file
View File

@@ -0,0 +1,6 @@
/**
* 可乐不加冰 - SillyTavern 插件入口
* 说明:为便于维护,实际逻辑在 main.js 内
*/
import './main.js';

5546
index.legacy.js Normal file

File diff suppressed because it is too large Load Diff

1580
main.js Normal file

File diff suppressed because it is too large Load Diff

12
manifest.json Normal file
View File

@@ -0,0 +1,12 @@
{
"display_name": "可乐不加冰",
"loading_order": 100,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Echo",
"version": "1.0.0",
"homePage": "",
"auto_update": false
}

594
message-menu.js Normal file
View File

@@ -0,0 +1,594 @@
/**
* 消息操作菜单
*/
import { getSettings, SUMMARY_MARKER_PREFIX, splitAIMessages } from './config.js';
import { saveSettingsDebounced } from '../../../../script.js';
import { currentChatIndex, openChat, showTypingIndicator, hideTypingIndicator, appendMessage } from './chat.js';
import { showToast } from './toast.js';
import { getContext } from '../../../extensions.js';
import { formatQuoteDate } from './utils.js';
import { isInGroupChat, getCurrentGroupIndex, openGroupChat } from './group-chat.js';
// 当前显示菜单的消息索引
let currentMenuMsgIndex = -1;
// 长按计时器
let longPressTimer = null;
// 是否正在长按
let isLongPress = false;
// 待引用的消息
let pendingQuote = null;
// 菜单项配置
const menuItems = [
{ id: 'copy', icon: 'copy', text: '复制' },
{ id: 'quote', icon: 'quote', text: '引用' },
{ id: 'recall', icon: 'recall', text: '撤回', userOnly: true },
{ id: 'delete', icon: 'delete', text: '删除' },
{ id: 'multiselect', icon: 'multiselect', text: '多选' }
];
// 图标SVG
const icons = {
copy: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>`,
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/>
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v4z"/>
</svg>`,
recall: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>`,
delete: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>`,
multiselect: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>`
};
// 创建菜单DOM
function createMenuElement(isUserMessage = false) {
const menu = document.createElement('div');
menu.className = 'wechat-msg-menu hidden';
menu.id = 'wechat-msg-menu';
const menuContent = document.createElement('div');
menuContent.className = 'wechat-msg-menu-content';
menuItems.forEach(item => {
// 跳过仅用户可用的菜单项(如果当前不是用户消息)
if (item.userOnly && !isUserMessage) return;
const menuItem = document.createElement('div');
menuItem.className = 'wechat-msg-menu-item';
menuItem.dataset.action = item.id;
menuItem.innerHTML = `
<div class="wechat-msg-menu-icon">${icons[item.id]}</div>
<div class="wechat-msg-menu-text">${item.text}</div>
`;
menuContent.appendChild(menuItem);
});
menu.appendChild(menuContent);
return menu;
}
// 显示菜单
export function showMessageMenu(msgElement, msgIndex, event) {
hideMessageMenu();
currentMenuMsgIndex = msgIndex;
// 检查是否为用户消息
const settings = getSettings();
const groupIndex = getCurrentGroupIndex();
let msg;
if (groupIndex >= 0) {
// 群聊模式
const groupChat = settings.groupChats?.[groupIndex];
msg = groupChat?.chatHistory?.[msgIndex];
} else {
// 单聊模式
const contact = settings.contacts[currentChatIndex];
msg = contact?.chatHistory?.[msgIndex];
}
// 优先从历史记录判断,其次从元素属性判断(处理分割显示的消息)
let isUserMessage = msg?.role === 'user';
if (msg === undefined) {
// 如果找不到消息记录,尝试从元素属性获取
const roleAttr = msgElement?.dataset?.msgRole || msgElement?.closest?.('[data-msg-role]')?.dataset?.msgRole;
isUserMessage = roleAttr === 'user';
}
// 移除旧菜单并创建新菜单(根据消息类型动态生成)
let menu = document.getElementById('wechat-msg-menu');
if (menu) {
menu.remove();
}
menu = createMenuElement(isUserMessage);
document.querySelector('.wechat-phone').appendChild(menu);
bindMenuEvents(menu);
// 计算位置
const msgRect = msgElement.getBoundingClientRect();
const phoneEl = document.querySelector('.wechat-phone');
const phoneRect = phoneEl.getBoundingClientRect();
// 相对于手机容器的位置
const relativeTop = msgRect.top - phoneRect.top;
const relativeLeft = msgRect.left - phoneRect.left;
menu.classList.remove('hidden');
// 获取菜单尺寸
const menuRect = menu.getBoundingClientRect();
// 默认显示在消息上方
let top = relativeTop - menuRect.height - 8;
let left = relativeLeft + (msgRect.width / 2) - (menuRect.width / 2);
// 如果上方空间不够,显示在下方
if (top < 50) {
top = relativeTop + msgRect.height + 8;
}
// 左右边界检查
if (left < 10) left = 10;
if (left + menuRect.width > phoneRect.width - 10) {
left = phoneRect.width - menuRect.width - 10;
}
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
// 点击其他地方关闭菜单
setTimeout(() => {
document.addEventListener('click', handleOutsideClick);
document.addEventListener('touchstart', handleOutsideClick);
}, 10);
}
// 隐藏菜单
export function hideMessageMenu() {
const menu = document.getElementById('wechat-msg-menu');
if (menu) {
menu.classList.add('hidden');
}
currentMenuMsgIndex = -1;
document.removeEventListener('click', handleOutsideClick);
document.removeEventListener('touchstart', handleOutsideClick);
}
// 点击外部关闭
function handleOutsideClick(e) {
const menu = document.getElementById('wechat-msg-menu');
if (menu && !menu.contains(e.target)) {
hideMessageMenu();
}
}
// 绑定菜单事件
function bindMenuEvents(menu) {
menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.wechat-msg-menu-item');
if (!menuItem) return;
const action = menuItem.dataset.action;
handleMenuAction(action, currentMenuMsgIndex);
hideMessageMenu();
});
}
// 处理菜单操作
function handleMenuAction(action, msgIndex) {
const settings = getSettings();
const groupIndex = getCurrentGroupIndex();
let chatHistory, contact, groupChat;
if (groupIndex >= 0) {
// 群聊模式
groupChat = settings.groupChats?.[groupIndex];
if (!groupChat || !groupChat.chatHistory || msgIndex < 0) return;
chatHistory = groupChat.chatHistory;
} else {
// 单聊模式
contact = settings.contacts[currentChatIndex];
if (!contact || !contact.chatHistory || msgIndex < 0) return;
chatHistory = contact.chatHistory;
}
const msg = chatHistory[msgIndex];
if (!msg) return;
switch (action) {
case 'copy':
copyMessage(msg.content);
break;
case 'quote':
quoteMessage(msg, groupIndex >= 0, groupChat);
break;
case 'recall':
if (groupIndex >= 0) {
recallGroupMessage(msgIndex, groupChat);
} else {
recallMessage(msgIndex, contact);
}
break;
case 'delete':
if (groupIndex >= 0) {
deleteGroupMessage(msgIndex, groupChat);
} else {
deleteMessage(msgIndex, contact);
}
break;
case 'multiselect':
showToast('多选功能开发中');
break;
}
}
// 复制消息
function copyMessage(content) {
navigator.clipboard.writeText(content).then(() => {
showToast('已复制');
}).catch(() => {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('已复制');
});
}
// 引用消息 - 设置待引用状态
function quoteMessage(msg, isGroupChat = false, groupChat = null) {
const settings = getSettings();
const context = getContext();
// 确定发送者名称
let senderName;
if (msg.role === 'user') {
senderName = context?.name1 || '我';
} else if (isGroupChat) {
// 群聊模式:使用消息中存储的角色名
senderName = msg.characterName || '群成员';
} else {
// 单聊模式:使用联系人名称
const contact = settings.contacts[currentChatIndex];
senderName = contact?.name || '对方';
}
// 格式化日期
const date = formatQuoteDate(msg.timestamp);
// 设置待引用消息
const isMusic = msg.isMusic === true;
let quoteContent = msg.content;
if (isMusic && msg.musicInfo) {
const artist = (msg.musicInfo.artist || '').toString().trim();
const name = (msg.musicInfo.name || '').toString().trim();
quoteContent = artist && name ? `${artist}-${name}` : (name || artist || msg.content);
}
pendingQuote = {
content: quoteContent,
sender: senderName,
date: date,
isVoice: msg.isVoice === true,
isPhoto: msg.isPhoto === true,
isSticker: msg.isSticker === true,
isMusic: isMusic
};
// 显示引用预览条
showQuotePreview();
// 聚焦输入框
const input = document.getElementById('wechat-input');
if (input) {
input.focus();
}
}
// 显示引用预览条
function showQuotePreview() {
if (!pendingQuote) return;
// 移除已有的预览条
hideQuotePreview();
const inputArea = document.querySelector('.wechat-chat-input');
if (!inputArea) return;
const previewBar = document.createElement('div');
previewBar.className = 'wechat-quote-preview';
previewBar.id = 'wechat-quote-preview';
// 根据消息类型生成显示文本
let contentText;
if (pendingQuote.isVoice) {
const seconds = Math.max(2, Math.min(60, Math.ceil(pendingQuote.content.length / 3)));
contentText = `[语音] ${seconds}"`;
} else if (pendingQuote.isPhoto) {
contentText = '[照片]';
} else if (pendingQuote.isSticker) {
contentText = '[表情]';
} else {
contentText = pendingQuote.content.length > 25
? pendingQuote.content.substring(0, 25) + '...'
: pendingQuote.content;
}
previewBar.innerHTML = `
<div class="wechat-quote-preview-content">
<span class="wechat-quote-preview-sender">${pendingQuote.sender}:</span>
<span class="wechat-quote-preview-text">${contentText}</span>
</div>
<button class="wechat-quote-preview-close" id="wechat-quote-close">×</button>
`;
// 插入到输入框下方
inputArea.parentNode.insertBefore(previewBar, inputArea.nextSibling);
// 绑定关闭按钮事件
document.getElementById('wechat-quote-close').addEventListener('click', clearQuote);
}
// 隐藏引用预览条
function hideQuotePreview() {
const preview = document.getElementById('wechat-quote-preview');
if (preview) {
preview.remove();
}
}
// 获取待引用消息
export function getPendingQuote() {
return pendingQuote;
}
// 清除引用
export function clearQuote() {
pendingQuote = null;
hideQuotePreview();
}
// 设置引用(供外部调用)
export function setQuote(quote) {
if (!quote || !quote.content) return;
pendingQuote = {
content: quote.content,
sender: quote.sender || '用户',
date: quote.date || '',
isVoice: quote.isVoice === true,
isPhoto: quote.isPhoto === true,
isSticker: quote.isSticker === true,
isMusic: quote.isMusic === true
};
showQuotePreview();
// 聚焦输入框
const input = document.getElementById('wechat-input');
if (input) {
input.focus();
}
}
// 删除消息
function deleteMessage(msgIndex, contact) {
contact.chatHistory.splice(msgIndex, 1);
saveSettingsDebounced();
// 刷新聊天界面
openChat(currentChatIndex);
showToast('已删除');
}
// 撤回消息
async function recallMessage(msgIndex, contact) {
const msg = contact.chatHistory[msgIndex];
if (!msg) return;
// 只能撤回自己的消息
if (msg.role !== 'user') {
showToast('只能撤回自己的消息');
return;
}
// 标记为撤回
msg.isRecalled = true;
msg.originalContent = msg.content;
msg.content = '';
saveSettingsDebounced();
// 刷新聊天界面
openChat(currentChatIndex);
showToast('已撤回');
// 触发AI回复
try {
showTypingIndicator(contact);
const { callAI } = await import('./ai.js');
const aiResponse = await callAI(contact, '[用户撤回了一条消息]');
hideTypingIndicator();
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')}`;
// 解析AI回复可能有多条消息
const aiMessages = splitAIMessages(aiResponse);
for (const aiMsg of aiMessages) {
let finalMsg = aiMsg;
let isVoice = false;
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
finalMsg = voiceMatch[1];
isVoice = true;
}
contact.chatHistory.push({
role: 'assistant',
content: finalMsg,
time: timeStr,
timestamp: Date.now(),
isVoice: isVoice
});
appendMessage('assistant', finalMsg, contact, isVoice);
}
contact.lastMessage = aiMessages[aiMessages.length - 1];
saveSettingsDebounced();
} catch (err) {
hideTypingIndicator();
console.error('[可乐] 撤回后AI回复失败:', err);
}
}
// 删除群聊消息
function deleteGroupMessage(msgIndex, groupChat) {
const groupIndex = getCurrentGroupIndex();
if (groupIndex < 0) return;
groupChat.chatHistory.splice(msgIndex, 1);
saveSettingsDebounced();
// 刷新群聊界面
openGroupChat(groupIndex);
showToast('已删除');
}
// 撤回群聊消息
async function recallGroupMessage(msgIndex, groupChat) {
const groupIndex = getCurrentGroupIndex();
if (groupIndex < 0) return;
const msg = groupChat.chatHistory[msgIndex];
if (!msg) return;
// 只能撤回自己的消息
if (msg.role !== 'user') {
showToast('只能撤回自己的消息');
return;
}
// 标记为撤回
msg.isRecalled = true;
msg.originalContent = msg.content;
msg.content = '';
saveSettingsDebounced();
// 刷新群聊界面
openGroupChat(groupIndex);
showToast('已撤回');
}
// 绑定消息气泡事件
export function bindMessageBubbleEvents(container) {
const bubbles = container.querySelectorAll('.wechat-message-bubble, .wechat-voice-bubble');
bubbles.forEach((bubble, index) => {
if (bubble.dataset.menuBound) return;
bubble.dataset.menuBound = 'true';
// 获取真实的消息索引
const msgElement = bubble.closest('.wechat-message');
if (!msgElement) return;
// 计算消息索引(跳过时间标签)
const allMessages = Array.from(container.querySelectorAll('.wechat-message'));
const msgIndex = allMessages.indexOf(msgElement);
// PC端单击
bubble.addEventListener('click', (e) => {
if (isLongPress) {
isLongPress = false;
return;
}
// 语音气泡点击展开文本,不显示菜单
if (bubble.classList.contains('wechat-voice-bubble')) return;
e.stopPropagation();
showMessageMenu(bubble, getRealMsgIndex(container, msgElement), e);
});
// 移动端:长按
bubble.addEventListener('touchstart', (e) => {
isLongPress = false;
longPressTimer = setTimeout(() => {
isLongPress = true;
e.preventDefault();
showMessageMenu(bubble, getRealMsgIndex(container, msgElement), e);
}, 500);
});
bubble.addEventListener('touchend', () => {
clearTimeout(longPressTimer);
});
bubble.addEventListener('touchmove', () => {
clearTimeout(longPressTimer);
});
});
}
// 获取真实的消息索引(排除时间标签等)
function getRealMsgIndex(container, msgElement) {
const settings = getSettings();
const contact = settings.contacts[currentChatIndex];
if (!contact || !contact.chatHistory) return -1;
// 获取所有消息元素(不含时间标签)
const allMsgElements = Array.from(container.querySelectorAll('.wechat-message:not(.wechat-typing-wrapper)'));
const visualIndex = allMsgElements.indexOf(msgElement);
if (visualIndex < 0) return -1;
// 需要计算真实索引chatHistory中可能包含marker消息和撤回消息
// 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算
let realIndex = -1;
let visualCount = 0;
for (let i = 0; i < contact.chatHistory.length; i++) {
const msg = contact.chatHistory[i];
// 跳过marker消息和撤回消息
if (msg.isMarker || msg.content?.startsWith(SUMMARY_MARKER_PREFIX) || msg.isRecalled) continue;
// 计算这条消息渲染成几个可视消息
let visualMsgCount = 1;
const content = msg.content || '';
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
if (!isSpecial && content.indexOf('|||') >= 0) {
// 按 ||| 分割后有多少个非空部分
const parts = content.split('|||').map(p => p.trim()).filter(p => p);
visualMsgCount = parts.length || 1;
}
// 检查 visualIndex 是否落在这条消息的范围内
if (visualIndex >= visualCount && visualIndex < visualCount + visualMsgCount) {
realIndex = i;
break;
}
visualCount += visualMsgCount;
}
return realIndex;
}

2290
moments.js Normal file

File diff suppressed because it is too large Load Diff

1226
music.js Normal file

File diff suppressed because it is too large Load Diff

1302
phone-html.js Normal file

File diff suppressed because it is too large Load Diff

214
phone.js Normal file
View File

@@ -0,0 +1,214 @@
/**
* 手机面板:显示/隐藏、自动居中、拖拽定位
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings } from './config.js';
import { getCurrentTime } from './utils.js';
let phoneAutoCenteringBound = false;
let phoneManuallyPositioned = false;
export function centerPhoneInViewport({ force = false } = {}) {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
if (!force && phone.classList.contains('hidden')) return;
const settings = getSettings();
// 用户手动拖拽后,不再自动居中(除非 force
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');
}
export 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 = getSettings();
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 = getSettings();
delete settings.phonePosition;
saveSettingsDebounced();
centerPhoneInViewport({ force: true });
});
}
export 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);
}
export function togglePhone() {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
const settings = getSettings();
phone.classList.toggle('hidden');
settings.phoneVisible = !phone.classList.contains('hidden');
saveSettingsDebounced();
if (settings.phoneVisible) {
const timeEl = document.querySelector('.wechat-statusbar-time');
if (timeEl) timeEl.textContent = getCurrentTime();
centerPhoneInViewport();
setTimeout(() => centerPhoneInViewport({ force: true }), 150);
}
}

32
settings-ui.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* 设置页/服务页相关的 UI 逻辑(不包含业务模块)
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getSettings } from './config.js';
export function toggleDarkMode() {
const phone = document.getElementById('wechat-phone');
const toggle = document.getElementById('wechat-dark-toggle');
if (!phone || !toggle) return;
const settings = getSettings();
settings.darkMode = !settings.darkMode;
phone.classList.toggle('wechat-dark', settings.darkMode);
toggle.classList.toggle('on', settings.darkMode);
saveSettingsDebounced();
}
export function refreshContextTags() {
const settings = getSettings();
const tagsContainer = document.getElementById('wechat-context-tags');
if (!tagsContainer) return;
const tags = settings.contextTags || [];
tagsContainer.innerHTML = tags.map((tag, i) => `
<div class="wechat-context-tag-item" data-index="${i}">
<span>&lt;${tag}&gt;</span>
<button class="wechat-tag-del-btn" data-index="${i}">×</button>
</div>
`).join('') + '<button class="wechat-tag-add-btn" id="wechat-context-add-tag">+</button>';
}

143
st-integration.js Normal file
View File

@@ -0,0 +1,143 @@
/**
* 与 SillyTavern 的集成:作者注释注入、监听主聊天消息、扩展菜单按钮
*/
import { getContext } from '../../../extensions.js';
import { authorNoteTemplate, extensionName, getSettings } from './config.js';
import { showToast } from './toast.js';
import { togglePhone } from './phone.js';
import { parseWeChatMessage } from './utils.js';
// 注入作者注释(微信格式指南)
export function injectAuthorNote() {
try {
const settings = getSettings();
// 优先使用自定义模板
const template = settings.authorNoteCustom || authorNoteTemplate;
const context = getContext();
if (context?.setExtensionPrompt) {
context.setExtensionPrompt(extensionName, template, 1, 0);
showToast('微信格式提示已注入');
return;
}
const authorNoteTextarea = document.querySelector('#author_note_text');
if (authorNoteTextarea) {
authorNoteTextarea.value = template;
authorNoteTextarea.dispatchEvent(new Event('input'));
showToast('微信格式提示已注入');
return;
}
showToast('无法找到作者注释区域', '🧊');
console.log('作者注释模板:', template);
} catch (err) {
console.error('[可乐] 注入作者注释失败:', err);
showToast('注入失败,请手动添加', '⚠️');
}
}
// 监听酒馆主聊天消息更新(用于识别微信格式)
export function setupMessageObserver() {
const context = getContext();
if (!context) return;
const chatContainer = document.getElementById('chat');
if (!chatContainer) return;
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) return;
const wechatMessages = parseWeChatMessage(mesText.textContent);
if (wechatMessages.length > 0) {
console.log('检测到微信格式消息:', wechatMessages);
}
}
});
});
});
observer.observe(chatContainer, { childList: true, subtree: true });
}
// 添加扩展按钮到酒馆扩展菜单
export function addExtensionButton() {
console.log('[可乐] 开始添加扩展按钮...');
// 方法1: 直接查找 extensionsMenu (legacy 方式)
const extensionsMenu = document.getElementById('extensionsMenu');
if (extensionsMenu) {
console.log('[可乐] 找到 extensionsMenu');
addMenuItemToMenu(extensionsMenu);
return;
}
// 方法2: 监听魔法棒点击
const wandButton = document.getElementById('extensionsMenuButton');
if (wandButton) {
console.log('[可乐] 找到魔法棒按钮,添加点击监听');
wandButton.addEventListener('click', () => {
console.log('[可乐] 魔法棒被点击');
// 多次尝试,因为菜单可能需要时间渲染
setTimeout(tryAddMenuItem, 10);
setTimeout(tryAddMenuItem, 50);
setTimeout(tryAddMenuItem, 100);
setTimeout(tryAddMenuItem, 200);
});
} else {
console.log('[可乐] 未找到魔法棒按钮500ms后重试');
setTimeout(addExtensionButton, 500);
}
}
// 尝试添加菜单项
function tryAddMenuItem() {
if (document.getElementById('wechat-extension-menu-item')) {
console.log('[可乐] 菜单项已存在');
return;
}
// 遍历所有元素,找包含特定文本的菜单
const allElements = document.querySelectorAll('*');
for (const el of allElements) {
// 查找直接包含菜单项文本的容器
if (el.children.length > 3 && el.children.length < 30) {
const text = el.textContent || '';
if ((text.includes('打开数据库') || text.includes('Open DB') || text.includes('附加文件') || text.includes('Attach File'))
&& !text.includes('可乐')) {
console.log('[可乐] 找到菜单容器:', el.tagName, el.className);
addMenuItemToMenu(el);
return;
}
}
}
console.log('[可乐] 未找到合适的菜单容器');
}
// 添加菜单项到菜单
function addMenuItemToMenu(menu) {
if (document.getElementById('wechat-extension-menu-item')) return;
const menuItem = document.createElement('div');
menuItem.id = 'wechat-extension-menu-item';
menuItem.className = 'list-group-item flex-container flexGap5';
menuItem.innerHTML = `
<span class="fa-solid fa-comment-dots"></span>
可乐
`;
menuItem.style.cursor = 'pointer';
menuItem.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
togglePhone();
menu.style.display = 'none';
});
menu.appendChild(menuItem);
console.log('[可乐] ✅ 扩展按钮已添加!');
}

7321
style.css Normal file

File diff suppressed because it is too large Load Diff

823
summary.js Normal file
View File

@@ -0,0 +1,823 @@
/**
* 总结功能
*/
import { saveSettingsDebounced } from '../../../../script.js';
import { getContext } from '../../../extensions.js';
import { loadWorldInfo, saveWorldInfo, createNewWorldInfo, world_names } from '../../../world-info.js';
import { getSettings, getCupName, SUMMARY_MARKER_PREFIX, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js';
import { sleep, escapeHtml } from './utils.js';
import { addErrorLog } from './history-logs.js';
// 替换占位符 {{user}} 和 {{char}}
function replacePlaceholders(content, userName, charName) {
if (!content) return content;
return content
.replace(/\{\{user\}\}/gi, userName)
.replace(/\{\{char\}\}/gi, charName);
}
// 获取指定聊天的下一杯编号
export function getNextCupNumber(lorebookName = null) {
const settings = getSettings();
const selectedLorebooks = settings.selectedLorebooks || [];
if (!lorebookName) return 1;
const lorebook = selectedLorebooks.find(lb => lb.name === lorebookName);
if (lorebook && lorebook.entries) {
return lorebook.entries.length + 1;
}
return 1;
}
// 刷新总结聊天列表
export function refreshSummaryChatList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const listEl = document.getElementById('wechat-summary-chat-list');
if (!listEl) return;
let html = '';
// 单聊
contacts.forEach((contact, idx) => {
const chatHistory = contact.chatHistory || [];
// 计算未总结的消息数量
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const newMsgCount = chatHistory.slice(lastMarkerIndex + 1).filter(m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX)).length;
if (newMsgCount > 0) {
html += `
<div class="wechat-summary-chat-item" style="display: flex; align-items: center; padding: 6px 4px; cursor: pointer; border-radius: 4px; margin-bottom: 4px;">
<input type="checkbox" class="wechat-summary-chat-check" data-type="contact" data-index="${idx}" checked style="margin-right: 8px; cursor: pointer;">
<span style="flex: 1; font-size: 13px;">${escapeHtml(contact.name)}</span>
<span style="font-size: 11px; color: var(--wechat-text-secondary);">${newMsgCount}条新消息</span>
</div>
`;
}
});
// 群聊
groupChats.forEach((group, idx) => {
const chatHistory = group.chatHistory || [];
// 计算未总结的消息数量
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const newMsgCount = chatHistory.slice(lastMarkerIndex + 1).filter(m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX)).length;
if (newMsgCount > 0) {
html += `
<div class="wechat-summary-chat-item" style="display: flex; align-items: center; padding: 6px 4px; cursor: pointer; border-radius: 4px; margin-bottom: 4px;">
<input type="checkbox" class="wechat-summary-chat-check" data-type="group" data-index="${idx}" checked style="margin-right: 8px; cursor: pointer;">
<span style="flex: 1; font-size: 13px;">👥 ${escapeHtml(group.name)}</span>
<span style="font-size: 11px; color: var(--wechat-text-secondary);">${newMsgCount}条新消息</span>
</div>
`;
}
});
if (!html) {
html = '<div style="text-align: center; color: var(--wechat-text-secondary); padding: 20px; font-size: 13px;">暂无新的聊天记录</div>';
}
listEl.innerHTML = html;
// 点击行也能切换checkbox
listEl.querySelectorAll('.wechat-summary-chat-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.type !== 'checkbox') {
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = !checkbox.checked;
}
});
});
}
// 全选/取消全选
export function selectAllSummaryChats(select) {
const checkboxes = document.querySelectorAll('.wechat-summary-chat-check');
checkboxes.forEach(cb => cb.checked = select);
}
// 获取选中的聊天
export function getSelectedChats() {
const checkboxes = document.querySelectorAll('.wechat-summary-chat-check:checked');
const selected = {
contacts: [],
groups: []
};
checkboxes.forEach(cb => {
const type = cb.dataset.type;
const index = parseInt(cb.dataset.index);
if (type === 'contact') {
selected.contacts.push(index);
} else if (type === 'group') {
selected.groups.push(index);
}
});
return selected;
}
// 收集所有联系人的聊天记录(只收集最后一个标记之后的内容)
export function collectAllChatHistory(selectedFilter = null) {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const allChats = [];
// 收集单聊
contacts.forEach((contact, idx) => {
// 如果有过滤器,检查是否被选中
if (selectedFilter && !selectedFilter.contacts.includes(idx)) return;
const chatHistory = contact.chatHistory || [];
if (chatHistory.length === 0) return;
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const startIndex = lastMarkerIndex + 1;
const newMessages = chatHistory.slice(startIndex);
const realMessages = newMessages.filter(msg =>
!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)
);
if (realMessages.length > 0) {
allChats.push({
type: 'contact',
index: idx,
contactName: `【可乐】和${contact.name}的聊天`,
contactDescription: contact.description || '',
messages: realMessages.map(msg => ({
role: msg.role,
content: msg.content,
time: msg.time || '',
isVoice: msg.isVoice || false
}))
});
}
});
// 收集群聊
groupChats.forEach((group, idx) => {
// 如果有过滤器,检查是否被选中
if (selectedFilter && !selectedFilter.groups.includes(idx)) return;
const chatHistory = group.chatHistory || [];
if (chatHistory.length === 0) return;
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const startIndex = lastMarkerIndex + 1;
const newMessages = chatHistory.slice(startIndex);
const realMessages = newMessages.filter(msg =>
!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)
);
if (realMessages.length > 0) {
// 获取群成员名称列表
const memberNames = (group.memberIds || []).map(id => {
const contact = settings.contacts.find(c => c.id === id);
return contact?.name || '未知';
});
const memberNamesStr = memberNames.join(',');
// 收集群聊消息,包含发言者信息
allChats.push({
type: 'group',
index: idx,
contactName: `【可乐】和${memberNamesStr}的聊天`,
contactDescription: `成员:${Math.min((group.memberIds?.length || 0), 3) + 1}`,
messages: realMessages.map(msg => ({
role: msg.role,
content: msg.content,
characterName: msg.characterName || '',
time: msg.time || '',
isVoice: msg.isVoice || false
}))
});
}
});
return allChats;
}
// 在所有联系人的聊天记录中插入标记
export function insertSummaryMarker(cupNumber, selectedFilter = null) {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const marker = `${SUMMARY_MARKER_PREFIX}${cupNumber}`;
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
// 单聊
contacts.forEach((contact, idx) => {
if (selectedFilter && !selectedFilter.contacts.includes(idx)) return;
if (!contact.chatHistory) contact.chatHistory = [];
let hasNewMessages = false;
for (let i = contact.chatHistory.length - 1; i >= 0; i--) {
const msg = contact.chatHistory[i];
if (msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) break;
if (!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
hasNewMessages = true;
break;
}
}
if (hasNewMessages || contact.chatHistory.length === 0) {
const lastMsg = contact.chatHistory[contact.chatHistory.length - 1];
if (!lastMsg?.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
contact.chatHistory.push({
role: 'system',
content: marker,
time: timeStr,
timestamp: Date.now(),
isMarker: true
});
}
}
});
// 群聊
groupChats.forEach((group, idx) => {
if (selectedFilter && !selectedFilter.groups.includes(idx)) return;
if (!group.chatHistory) group.chatHistory = [];
let hasNewMessages = false;
for (let i = group.chatHistory.length - 1; i >= 0; i--) {
const msg = group.chatHistory[i];
if (msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) break;
if (!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
hasNewMessages = true;
break;
}
}
if (hasNewMessages || group.chatHistory.length === 0) {
const lastMsg = group.chatHistory[group.chatHistory.length - 1];
if (!lastMsg?.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
group.chatHistory.push({
role: 'system',
content: marker,
time: timeStr,
timestamp: Date.now(),
isMarker: true
});
}
}
});
saveSettingsDebounced();
}
// 生成总结提示词
export function generateSummaryPrompt(allChats, cupNumber) {
let prompt = `你是一位客观、精准的结构化事件记录员。你的任务是像历史学家记录史实一样,从这段【线上聊天记录】中提取并记录关键信息。
【核心原则】
- 客观准确:只记录实际发生的事件,不添加主观推测或情感评价
- 结构清晰:按时间顺序提取关键节点
- 忠于原文:尽量保留原始表述,避免过度概括
- 重点突出:只记录推动事件发展的关键信息
【记录要点】
- 关系状态的实际变化(约定、承诺、矛盾、和解等具体事件)
- 重要的对话内容和决定
- 人物之间的互动行为
- 情感表达的关键时刻
【输出格式要求】
- 只输出一个JSON对象
- 不要使用markdown代码块
- 直接以 { 开头,以 } 结尾
- keys: 3-5个能代表本次聊天核心内容的关键词
- content: 按"序号: 事件记录"格式列出关键节点(每条一行)
- comment: "${getCupName(cupNumber)}"
【JSON示例】
{"keys":["约会","告白","接受"],"content":"1: {{user}}邀请{{char}}周末见面\\n2: {{char}}表示期待并确认时间\\n3: {{user}}表达好感,{{char}}积极回应","comment":"${getCupName(cupNumber)}"}
【线上聊天记录】
`;
allChats.forEach(chat => {
prompt += `\n--- ${chat.contactName} ---\n`;
chat.messages.slice(-300).forEach(msg => {
let speaker;
if (msg.role === 'user') {
speaker = '{{user}}';
} else if (chat.type === 'group' && msg.characterName) {
speaker = msg.characterName;
} else {
// 从"【可乐】和xxx的聊天"格式中提取联系人名字
const match = chat.contactName.match(/【可乐】和(.+)的聊天/);
speaker = match ? match[1] : '{{char}}';
}
const timeStr = msg.time ? `[${msg.time}] ` : '';
prompt += `${timeStr}${speaker}: ${msg.content}\n`;
});
});
prompt += `\n请从以上线上聊天记录中提取关键事件节点,输出${getCupName(cupNumber)}的JSON`;
return prompt;
}
// 调用总结API
export async function callSummaryAPI(prompt) {
const settings = getSettings();
const apiUrl = settings.summaryApiUrl;
const apiKey = settings.summaryApiKey;
const model = settings.summarySelectedModel;
if (!apiUrl || !apiKey || !model) {
throw new Error('请先配置总结APIURL、密钥和模型');
}
const chatUrl = apiUrl.replace(/\/$/, '') + '/chat/completions';
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: '你是一个专业的内容分析师,擅长从对话中提取关键信息并生成结构化的世界书条目。' },
{ role: 'user', content: prompt }
],
temperature: 1,
max_tokens: 8196
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error?.message || `HTTP ${response.status}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
// 解析JSON
const parsed = parseJSONResponse(content);
if (parsed) return parsed;
throw new Error('AI返回内容为空或无法解析');
}
// 解析JSON响应
function parseJSONResponse(content) {
// 方法1: 直接解析
try {
const result = JSON.parse(content);
if (result.keys && result.content) return result;
if (result.entries?.[0]) return result.entries[0];
} catch (e) {}
// 方法2: 移除markdown代码块
try {
const cleaned = content.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
const result = JSON.parse(cleaned);
if (result.keys && result.content) return result;
} catch (e) {}
// 方法3: 提取JSON部分
try {
const firstBrace = content.indexOf('{');
const lastBrace = content.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
const result = JSON.parse(content.substring(firstBrace, lastBrace + 1));
if (result.keys && result.content) return result;
}
} catch (e) {}
// 降级方案
if (content && content.trim().length > 20) {
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
return {
keys: [...new Set(words)].slice(0, 5),
content: content.substring(0, 800).replace(/```[\s\S]*?```/g, '').trim(),
comment: '感情记录'
};
}
return null;
}
// 保存条目到收藏
export function saveEntryToFavorites(entry, cupNumber, lorebookName) {
const settings = getSettings();
if (!settings.selectedLorebooks) settings.selectedLorebooks = [];
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
// 获取用户名和角色名用于替换占位符
const context = getContext();
const userName = context?.name1 || 'User';
// 从世界书名称中提取角色名格式【可乐】和xxx的聊天
let charName = lorebookName;
if (lorebookName.startsWith(LOREBOOK_NAME_PREFIX) && lorebookName.endsWith(LOREBOOK_NAME_SUFFIX)) {
charName = lorebookName.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
let lorebook = settings.selectedLorebooks.find(lb => lb.name === lorebookName);
if (!lorebook) {
lorebook = {
name: lorebookName,
addedTime: timeStr,
entries: [],
enabled: true,
fromSummary: true
};
settings.selectedLorebooks.push(lorebook);
}
// 替换 {{user}} 和 {{char}} 占位符
const processedContent = replacePlaceholders(entry.content || '', userName, charName);
const processedKeys = (entry.keys || []).map(key => replacePlaceholders(key, userName, charName));
const newEntry = {
uid: cupNumber - 1,
keys: processedKeys,
content: processedContent,
comment: entry.comment || getCupName(cupNumber),
enabled: true,
case_sensitive: false,
priority: 10,
id: cupNumber - 1,
addedTime: timeStr
};
lorebook.entries.push(newEntry);
lorebook.lastUpdated = timeStr;
saveSettingsDebounced();
return lorebook;
}
// 同步条目到酒馆世界书
export async function syncEntryToSillyTavern(entry, cupNumber, lorebookName) {
try {
const name = lorebookName;
// 获取用户名和角色名用于替换占位符
const context = getContext();
const userName = context?.name1 || 'User';
let charName = lorebookName;
if (lorebookName.startsWith(LOREBOOK_NAME_PREFIX) && lorebookName.endsWith(LOREBOOK_NAME_SUFFIX)) {
charName = lorebookName.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
// 替换占位符
const processedContent = replacePlaceholders(entry.content || '', userName, charName);
const processedKeys = (entry.keys || []).map(key => replacePlaceholders(key, userName, charName));
const newEntry = {
uid: cupNumber - 1,
key: processedKeys,
keysecondary: [],
comment: entry.comment || getCupName(cupNumber),
content: processedContent,
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: true,
order: 100,
position: 0,
disable: false,
excludeRecursion: false,
preventRecursion: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 4,
group: '',
caseSensitive: false,
role: 0
};
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (!worldExists) {
if (typeof createNewWorldInfo === 'function') {
await createNewWorldInfo(name);
await sleep(500);
}
}
let worldInfo = { entries: {} };
if (typeof loadWorldInfo === 'function') {
const existingData = await loadWorldInfo(name);
if (existingData?.entries) worldInfo = existingData;
}
worldInfo.entries[cupNumber - 1] = newEntry;
if (typeof saveWorldInfo === 'function') {
await saveWorldInfo(name, worldInfo);
return true;
}
throw new Error('saveWorldInfo 函数不可用');
} catch (err) {
console.error('[可乐不加冰] 同步到酒馆失败:', err);
throw err;
}
}
// 执行总结主函数(按聊天分别处理,每个聊天有自己的世界书)
export async function executeSummary() {
const progressEl = document.getElementById('wechat-summary-progress');
const executeBtn = document.getElementById('wechat-summary-execute');
const updateProgress = (msg) => {
if (progressEl) progressEl.textContent = msg;
};
if (executeBtn) {
executeBtn.disabled = true;
executeBtn.textContent = '⏳ 处理中...';
}
try {
// 获取选中的聊天
const selectedFilter = getSelectedChats();
// 检查是否有选中项
if (selectedFilter.contacts.length === 0 && selectedFilter.groups.length === 0) {
throw new Error('请至少选择一个聊天进行总结');
}
updateProgress('📋 收集聊天记录...');
const allChats = collectAllChatHistory(selectedFilter);
if (allChats.length === 0) {
throw new Error('没有新的聊天记录需要总结');
}
const totalMessages = allChats.reduce((sum, chat) => sum + chat.messages.length, 0);
updateProgress('📋 收集到 ' + allChats.length + ' 个对话,共 ' + totalMessages + ' 条消息');
await sleep(500);
// 逐个处理每个聊天
let successCount = 0;
for (let i = 0; i < allChats.length; i++) {
const chat = allChats[i];
const lorebookName = chat.contactName; // 已经是【可乐】和xxx的聊天格式
const cupNumber = getNextCupNumber(lorebookName);
updateProgress('🍵 正在处理 ' + chat.contactName + ' (' + (i + 1) + '/' + allChats.length + ')...');
await sleep(300);
try {
// 为单个聊天生成总结
updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...');
const prompt = generateSummaryPrompt([chat], cupNumber);
const entry = await callSummaryAPI(prompt);
// 保存到收藏
saveEntryToFavorites(entry, cupNumber, lorebookName);
// 尝试同步到酒馆
try {
await syncEntryToSillyTavern(entry, cupNumber, lorebookName);
} catch (syncErr) {
console.error('[可乐] 同步 ' + lorebookName + ' 到酒馆失败:', syncErr);
addErrorLog(syncErr, '同步到酒馆');
}
// 为该聊天插入标记
const singleFilter = {
contacts: chat.type === 'contact' ? [chat.index] : [],
groups: chat.type === 'group' ? [chat.index] : []
};
insertSummaryMarker(cupNumber, singleFilter);
successCount++;
} catch (chatErr) {
console.error('[可乐] 处理 ' + chat.contactName + ' 失败:', chatErr);
addErrorLog(chatErr, '总结处理: ' + chat.contactName);
updateProgress('⚠️ ' + chat.contactName + ' 处理失败: ' + chatErr.message);
await sleep(1000);
}
}
if (successCount === allChats.length) {
updateProgress('✅ 完成!已为 ' + successCount + ' 个聊天生成总结');
} else {
updateProgress('✅ 完成 ' + successCount + '/' + allChats.length + ' 个聊天总结');
}
// 刷新收藏列表和聊天选择列表
import('./favorites.js').then(m => m.refreshFavoritesList());
refreshSummaryChatList();
} catch (err) {
console.error('[可乐] 执行总结失败:', err);
addErrorLog(err, '执行总结');
updateProgress('❌ 失败: ' + err.message);
} finally {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = '执行总结';
}
}
}
// 回退总结(从历史回顾中选择要回退的世界书)
export async function rollbackSummary() {
const settings = getSettings();
const progressEl = document.getElementById('wechat-summary-progress');
const rollbackBtn = document.getElementById('wechat-summary-rollback');
const updateProgress = (msg) => {
if (progressEl) progressEl.textContent = msg;
};
// 找到所有总结生成的世界书
const selectedLorebooks = settings.selectedLorebooks || [];
const summaryBooks = selectedLorebooks.filter(lb =>
lb.fromSummary === true ||
(lb.name && lb.name.startsWith('【可乐】和') && lb.name.endsWith('的聊天'))
);
if (summaryBooks.length === 0) {
updateProgress('🧊 没有可回退的总结');
return;
}
// 构建选择列表
const options = summaryBooks.map((lb, idx) => {
const entriesCount = lb.entries?.length || 0;
return (idx + 1) + '. ' + lb.name + ' (' + entriesCount + '杯)';
}).join('\n');
const choice = prompt('选择要回退的世界书(输入序号):\n\n' + options + '\n\n输入序号:');
if (!choice) return;
const choiceIdx = parseInt(choice) - 1;
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= summaryBooks.length) {
updateProgress('🧊 无效的选择');
return;
}
const targetBook = summaryBooks[choiceIdx];
const lorebookIdx = selectedLorebooks.findIndex(lb => lb.name === targetBook.name);
if (lorebookIdx < 0 || !targetBook.entries?.length) {
updateProgress('🧊 该世界书没有可回退的条目');
return;
}
const cupNumber = targetBook.entries.length;
if (!confirm(
'确定要回退「' + targetBook.name + '」的' + getCupName(cupNumber) + '总结吗?\n\n' +
'这将删除:\n1. 世界书中的' + getCupName(cupNumber) + '条目\n' +
'2. 相关聊天记录中的"' + SUMMARY_MARKER_PREFIX + cupNumber + '"标记'
)) {
return;
}
if (rollbackBtn) {
rollbackBtn.disabled = true;
rollbackBtn.textContent = '⏳ 回退中...';
}
try {
// 1) 从收藏中删除最后一个条目
targetBook.entries.pop();
updateProgress('✅ 已删除收藏中的条目...');
// 2) 从相关聊天记录中删除对应标记
const markerToRemove = SUMMARY_MARKER_PREFIX + cupNumber;
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
let removedCount = 0;
// 从单聊中移除
contacts.forEach(contact => {
if (!contact.chatHistory) return;
for (let i = contact.chatHistory.length - 1; i >= 0; i--) {
const msg = contact.chatHistory[i];
if (msg.content === markerToRemove ||
(msg.isMarker && msg.content?.startsWith(SUMMARY_MARKER_PREFIX + cupNumber))) {
contact.chatHistory.splice(i, 1);
removedCount++;
}
}
});
// 从群聊中移除
groupChats.forEach(group => {
if (!group.chatHistory) return;
for (let i = group.chatHistory.length - 1; i >= 0; i--) {
const msg = group.chatHistory[i];
if (msg.content === markerToRemove ||
(msg.isMarker && msg.content?.startsWith(SUMMARY_MARKER_PREFIX + cupNumber))) {
group.chatHistory.splice(i, 1);
removedCount++;
}
}
});
updateProgress('✅ 已移除 ' + removedCount + ' 个标记...');
// 如果世界书条目已清空从selectedLorebooks中移除整个世界书
if (targetBook.entries.length === 0) {
selectedLorebooks.splice(lorebookIdx, 1);
updateProgress('✅ 世界书已清空,已删除...');
}
saveSettingsDebounced();
// 3) 尝试同步删除酒馆世界书条目(或整个世界书)
try {
const name = targetBook.name;
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (worldExists && typeof loadWorldInfo === 'function' && typeof saveWorldInfo === 'function') {
const worldInfo = await loadWorldInfo(name);
if (worldInfo?.entries && worldInfo.entries[cupNumber - 1]) {
delete worldInfo.entries[cupNumber - 1];
// 检查酒馆世界书是否还有条目
const remainingEntries = Object.keys(worldInfo.entries).length;
if (remainingEntries === 0) {
// 如果没有条目了,尝试删除整个世界书
try {
const { deleteWorldInfo } = await import('../../../world-info.js');
if (typeof deleteWorldInfo === 'function') {
await deleteWorldInfo(name);
updateProgress('✅ 已删除酒馆世界书');
} else {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆(世界书已清空)');
}
} catch (delErr) {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆');
}
} else {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆');
}
} else {
updateProgress('✅ 本地回退完成(酒馆无需同步)');
}
} else {
updateProgress('✅ 本地回退完成(酒馆同步不可用)');
}
} catch (syncErr) {
console.error('[可乐] 回退同步到酒馆失败:', syncErr);
addErrorLog(syncErr, '回退同步');
updateProgress('✅ 本地回退完成(酒馆同步失败)');
}
import('./favorites.js').then(m => m.refreshFavoritesList());
refreshSummaryChatList();
} catch (err) {
console.error('[可乐] 回退总结失败:', err);
addErrorLog(err, '回退总结');
updateProgress('⚠️ 回退失败: ' + err.message);
} finally {
if (rollbackBtn) {
rollbackBtn.disabled = false;
rollbackBtn.textContent = '回退总结';
}
}
}

64
toast.js Normal file
View File

@@ -0,0 +1,64 @@
/**
* Toast 提示(显示在手机面板内)
*/
export function showToast(message, icon = '✅', durationMs = 2000) {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
const existingToast = phone.querySelector('.wechat-toast');
if (existingToast) existingToast.remove();
const toast = document.createElement('div');
toast.className = 'wechat-toast';
const iconEl = document.createElement('span');
iconEl.className = 'wechat-toast-icon';
iconEl.textContent = icon;
const textEl = document.createElement('span');
textEl.textContent = message;
toast.append(iconEl, textEl);
phone.appendChild(toast);
setTimeout(() => toast.remove(), durationMs);
}
/**
* 手机顶部通知横幅(像真实手机通知一样从顶部滑下)
* @param {string} title - 通知标题(如"微信"
* @param {string} message - 通知内容
* @param {number} durationMs - 显示时长默认3秒
*/
export function showNotificationBanner(title, message, durationMs = 3000) {
const phone = document.getElementById('wechat-phone');
if (!phone) return;
// 移除已有的通知横幅
const existingBanner = phone.querySelector('.wechat-notification-banner');
if (existingBanner) existingBanner.remove();
const banner = document.createElement('div');
banner.className = 'wechat-notification-banner';
// 设置动画时长
banner.style.animationDuration = `${durationMs}ms`;
// 获取当前时间
const now = new Date();
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
banner.innerHTML = `
<div class="wechat-notification-banner-content">
<div class="wechat-notification-banner-title">${title}</div>
<div class="wechat-notification-banner-text">${message}</div>
</div>
<div class="wechat-notification-banner-time">${timeStr}</div>
`;
phone.appendChild(banner);
// 动画结束后移除
setTimeout(() => banner.remove(), durationMs);
}

510
ui.js Normal file
View File

@@ -0,0 +1,510 @@
/**
* UI 生成函数
*/
import { getContext } from '../../../extensions.js';
import { extensionName, getSettings } from './config.js';
import { getCurrentTime, formatChatTime, escapeHtml } from './utils.js';
const GROUP_CHAT_MAX_AI_MEMBERS = 3;
function getLastRenderableMessage(chatHistory) {
const history = Array.isArray(chatHistory) ? chatHistory : [];
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
if (!msg) continue;
if (msg.isVoiceCallMessage === true || msg.isVideoCallMessage === true) continue;
if (msg.isMarker === true) continue;
if (msg.isRecalled === true && (!msg.content || !msg.content.toString().trim())) continue;
return msg;
}
return null;
}
// 获取用户头像HTML
export function getUserAvatarHTML() {
const settings = getSettings();
const context = getContext();
const userName = context?.name1 || 'User';
const firstChar = userName.charAt(0);
if (settings.userAvatar) {
return `<img src="${settings.userAvatar}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${firstChar}'">`;
}
const userAvatar = context?.user_avatar;
if (userAvatar) {
const avatarPaths = [
`/User Avatars/${userAvatar}`,
`/characters/${userAvatar}`,
userAvatar
];
return `<img src="${avatarPaths[0]}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${firstChar}'">`;
}
const stPersona = getUserPersonaFromST();
if (stPersona?.avatar) {
return `<img src="/User Avatars/${stPersona.avatar}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${firstChar}'">`;
}
return firstChar;
}
// 从酒馆获取用户设定
export function getUserPersonaFromST() {
try {
let name = '';
let description = '';
let avatar = '';
const context = getContext();
if (context) {
name = context.name1 || '';
avatar = context.user_avatar || '';
}
if (!name && typeof name1 !== 'undefined') {
name = name1;
}
if (typeof power_user !== 'undefined') {
if (power_user.persona_description) {
description = power_user.persona_description;
}
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;
}
}
}
if (!name && typeof user_avatar !== 'undefined') {
name = user_avatar.replace(/\.[^/.]+$/, '');
}
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;
}
// 生成聊天列表 HTML包含单聊和群聊
export function generateChatList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
// 处理单聊
const contactsWithChat = contacts.map((contact, index) => {
const chatHistory = contact.chatHistory || [];
const lastMsg = getLastRenderableMessage(chatHistory);
const lastMsgTime = lastMsg ? (lastMsg.timestamp || new Date(lastMsg.time).getTime() || 0) : 0;
const contactId = contact.id || `idx_${index}`;
return {
type: 'contact',
...contact,
id: contactId,
originalIndex: index,
lastMsg,
lastMsgTime
};
}).filter(c => c.lastMsg);
// 处理群聊
const groupsWithChat = groupChats.map((group, index) => {
const chatHistory = group.chatHistory || [];
const lastMsg = getLastRenderableMessage(chatHistory);
const lastMsgTime = lastMsg ? (lastMsg.timestamp || group.lastMessageTime || 0) : (group.lastMessageTime || 0);
return {
type: 'group',
...group,
originalIndex: index,
lastMsg,
lastMsgTime: lastMsgTime || Date.now()
};
});
// 合并并排序
const allChats = [...contactsWithChat, ...groupsWithChat].sort((a, b) => b.lastMsgTime - a.lastMsgTime);
if (allChats.length === 0) {
return `
<div class="wechat-empty">
<div class="wechat-empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" style="opacity: 0.4;">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="wechat-empty-text">暂无聊天记录<br>点击通讯录选择好友开始聊天</div>
</div>
`;
}
return allChats.map(chat => {
if (chat.type === 'group') {
return generateGroupChatItem(chat, settings);
} else {
return generateContactChatItem(chat);
}
}).join('');
}
// 生成单聊列表项
function generateContactChatItem(contact) {
const lastMsg = contact.lastMsg;
let preview = '';
if (lastMsg.type === 'voice' || lastMsg.isVoice) {
preview = '[语音]';
} else if (lastMsg.type === 'image' || lastMsg.isImage) {
preview = '[图片]';
} else if (lastMsg.type === 'sticker' || lastMsg.isSticker) {
preview = '[表情]';
} else {
preview = lastMsg.content || '';
// 处理内容中的特殊标签
if (preview.includes('<meme>')) {
preview = '[表情]';
} else if (preview.includes('<photo>') || preview.includes('<image>')) {
preview = '[图片]';
} else if (/\[表情[:].+?\]/.test(preview)) {
preview = '[表情]';
} else if (/\[语音[:].+?\]/.test(preview)) {
preview = '[语音]';
} else if (/\[照片[:].+?\]/.test(preview)) {
preview = '[图片]';
} else if (/\[图片[:].+?\]/.test(preview)) {
preview = '[图片]';
} else {
if (preview.length > 20) preview = preview.substring(0, 20) + '...';
}
}
const msgTime = contact.lastMsgTime ? formatChatTime(contact.lastMsgTime) : '';
const avatarContent = contact.avatar
? `<img src="${contact.avatar}" alt="${contact.name}">`
: `<span>${contact.name?.charAt(0) || '?'}</span>`;
// 未读消息红点
const unreadCount = contact.unreadCount || 0;
const badgeHtml = unreadCount > 0
? `<span class="wechat-chat-item-badge">${unreadCount > 99 ? '99+' : unreadCount}</span>`
: '';
return `
<div class="wechat-chat-item" data-contact-id="${contact.id}" data-index="${contact.originalIndex}">
<div class="wechat-chat-item-avatar">
${avatarContent}
</div>
<div class="wechat-chat-item-info">
<div class="wechat-chat-item-name">${contact.name || '未知'}</div>
<div class="wechat-chat-item-preview">${escapeHtml(preview)}</div>
</div>
<div class="wechat-chat-item-meta">
${badgeHtml}
<span class="wechat-chat-item-time">${msgTime}</span>
</div>
</div>
`;
}
// 生成群聊列表项
function generateGroupChatItem(group, settings) {
const lastMsg = group.lastMsg;
let preview = '';
if (lastMsg) {
const sender = lastMsg.characterName ? `[${lastMsg.characterName}]: ` : '';
if (lastMsg.isVoice) {
preview = `${sender}[语音]`;
} else if (lastMsg.isImage) {
preview = `${sender}[图片]`;
} else if (lastMsg.isSticker) {
preview = `${sender}[表情]`;
} else {
let content = lastMsg.content || '';
// 处理内容中的特殊标签
if (content.includes('<meme>')) {
content = '[表情]';
} else if (content.includes('<photo>') || content.includes('<image>')) {
content = '[图片]';
} else if (/\[表情[:].+?\]/.test(content)) {
content = '[表情]';
} else if (/\[语音[:].+?\]/.test(content)) {
content = '[语音]';
} else if (/\[照片[:].+?\]/.test(content)) {
content = '[图片]';
} else if (/\[图片[:].+?\]/.test(content)) {
content = '[图片]';
} else {
if (content.length > 15) content = content.substring(0, 15) + '...';
}
preview = `${sender}${content}`;
}
} else {
preview = '群聊已创建';
}
const msgTime = group.lastMsgTime ? formatChatTime(group.lastMsgTime) : '';
// 生成群头像(九宫格)
const memberIds = group.memberIds || [];
const groupMemberCount = Math.min(memberIds.length, GROUP_CHAT_MAX_AI_MEMBERS) + 1; // +1包含用户自己
const contactMembers = memberIds.map(id => settings.contacts?.find(c => c.id === id)).filter(Boolean);
const members = [{ __isUser: true }, ...contactMembers].slice(0, 4);
let avatarHtml = '';
if (members.length === 1 && members[0].__isUser) {
avatarHtml = getUserAvatarHTML();
} else if (members.length === 0) {
avatarHtml = `<span style="font-size: 18px;">👥</span>`;
} else if (members.length === 1) {
const m = members[0];
avatarHtml = m.avatar
? `<img src="${m.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`
: `<span>${m.name?.charAt(0) || '?'}</span>`;
} else {
// 九宫格布局
const gridSize = members.length <= 4 ? 2 : 3;
const itemSize = Math.floor(44 / gridSize) - 2;
avatarHtml = `<div style="display: grid; grid-template-columns: repeat(${gridSize}, 1fr); gap: 2px; width: 100%; height: 100%;">`;
members.forEach(m => {
if (m.__isUser) {
const userAvatar = getUserAvatarHTML();
const isImg = typeof userAvatar === 'string' && userAvatar.trim().startsWith('<img');
if (isImg) {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; overflow: hidden; border-radius: 2px;">${userAvatar}</div>`;
} else {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; background: var(--wechat-bg); border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 10px;">${escapeHtml((userAvatar || '我').toString().trim().charAt(0) || '我')}</div>`;
}
return;
}
if (m.avatar) {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; overflow: hidden; border-radius: 2px;"><img src="${m.avatar}" style="width: 100%; height: 100%; object-fit: cover;"></div>`;
} else {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; background: var(--wechat-bg); border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 10px;">${m.name?.charAt(0) || '?'}</div>`;
}
});
avatarHtml += `</div>`;
}
return `
<div class="wechat-chat-item wechat-chat-item-group" data-group-id="${group.id}" data-group-index="${group.originalIndex}">
<div class="wechat-chat-item-avatar" style="display: flex; align-items: center; justify-content: center;">${avatarHtml}</div>
<div class="wechat-chat-item-info">
<div class="wechat-chat-item-name">群聊(${groupMemberCount})</div>
<div class="wechat-chat-item-preview">${escapeHtml(preview)}</div>
</div>
<div class="wechat-chat-item-meta">
<span class="wechat-chat-item-time">${msgTime}</span>
</div>
</div>
`;
}
// 生成联系人列表 HTML
export function generateContactsList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
if (contacts.length === 0 && groupChats.length === 0) {
return `
<div class="wechat-empty">
<div class="wechat-empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" style="opacity: 0.4;">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<div class="wechat-empty-text">暂无聊天<br>点击右上角 + 导入角色卡</div>
</div>
`;
}
let html = '<div class="wechat-contacts-grid">';
// 生成群聊卡片
groupChats.forEach((group, index) => {
const memberIds = group.memberIds || [];
const groupMemberCount = Math.min(memberIds.length, GROUP_CHAT_MAX_AI_MEMBERS) + 1; // +1包含用户自己
const contactMembers = memberIds.map(id => settings.contacts?.find(c => c.id === id)).filter(Boolean);
const members = [{ __isUser: true }, ...contactMembers].slice(0, 4);
let avatarHtml = '';
if (members.length === 1 && members[0].__isUser) {
const userAvatar = getUserAvatarHTML();
const isImg = typeof userAvatar === 'string' && userAvatar.trim().startsWith('<img');
avatarHtml = isImg ? userAvatar : '';
avatarHtml += `<div class="wechat-card-fallback" style="${isImg ? 'display:none' : 'display:flex'}">${escapeHtml((userAvatar || '我').toString().trim().charAt(0) || '我')}</div>`;
} else if (members.length === 0) {
avatarHtml = `<div class="wechat-card-fallback" style="display:flex">👥</div>`;
} else if (members.length === 1) {
const m = members[0];
avatarHtml = m.avatar
? `<img src="${m.avatar}" alt="" onerror="this.style.display='none';this.parentElement.querySelector('.wechat-card-fallback').style.display='flex'">`
: '';
avatarHtml += `<div class="wechat-card-fallback" style="${m.avatar ? 'display:none' : 'display:flex'}">${m.name?.charAt(0) || '?'}</div>`;
} else {
// 九宫格头像
const gridSize = members.length <= 4 ? 2 : 3;
const itemSize = Math.floor(50 / gridSize) - 2;
avatarHtml = `<div style="display: grid; grid-template-columns: repeat(${gridSize}, 1fr); gap: 2px; width: 100%; height: 100%; padding: 4px; box-sizing: border-box;">`;
members.forEach(m => {
if (m.__isUser) {
const userAvatar = getUserAvatarHTML();
const isImg = typeof userAvatar === 'string' && userAvatar.trim().startsWith('<img');
if (isImg) {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; overflow: hidden; border-radius: 2px;">${userAvatar}</div>`;
} else {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; background: var(--wechat-bg); border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 10px;">${escapeHtml((userAvatar || '我').toString().trim().charAt(0) || '我')}</div>`;
}
return;
}
if (m.avatar) {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; overflow: hidden; border-radius: 2px;"><img src="${m.avatar}" style="width: 100%; height: 100%; object-fit: cover;"></div>`;
} else {
avatarHtml += `<div style="width: ${itemSize}px; height: ${itemSize}px; background: var(--wechat-bg); border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 10px;">${m.name?.charAt(0) || '?'}</div>`;
}
});
avatarHtml += `</div>`;
}
html += `
<div class="wechat-contact-card wechat-group-card" data-group-index="${index}">
<div class="wechat-card-swipe-wrapper">
<div class="wechat-card-content">
<div class="wechat-card-avatar wechat-group-avatar" data-group-index="${index}" title="点击进入群聊">
${avatarHtml}
</div>
<div class="wechat-card-name">群聊(${groupMemberCount})</div>
</div>
<div class="wechat-card-delete wechat-group-delete" data-group-index="${index}">
<span>删除</span>
</div>
</div>
</div>
`;
});
// 生成联系人卡片
contacts.forEach((contact, index) => {
const firstChar = contact.name ? contact.name.charAt(0) : '?';
const avatarContent = contact.avatar
? `<img src="${contact.avatar}" alt="" onerror="this.style.display='none';this.parentElement.querySelector('.wechat-card-fallback').style.display='flex'">`
: '';
html += `
<div class="wechat-contact-card" data-index="${index}">
<div class="wechat-card-swipe-wrapper">
<div class="wechat-card-content">
<div class="wechat-card-avatar" data-index="${index}" title="点击更换头像">
${avatarContent}
<div class="wechat-card-fallback" style="${contact.avatar ? 'display:none' : 'display:flex'}">${firstChar}</div>
</div>
<div class="wechat-card-name">${contact.name}</div>
</div>
<div class="wechat-card-delete" data-index="${index}">
<span>删除</span>
</div>
</div>
</div>
`;
});
html += '</div>';
return html;
}
// 刷新聊天列表
export function refreshChatList() {
const chatListEl = document.getElementById('wechat-chat-list');
if (chatListEl) {
chatListEl.innerHTML = generateChatList();
}
// 更新底部导航栏红点
updateTabBadge();
}
// 更新底部导航栏微信tab的红点
export function updateTabBadge() {
const settings = getSettings();
const contacts = settings.contacts || [];
// 计算总未读数
let totalUnread = 0;
contacts.forEach(contact => {
totalUnread += contact.unreadCount || 0;
});
// 更新所有页面的微信tab badge
const badges = document.querySelectorAll('.wechat-tab[data-tab="chat"] .wechat-tab-badge');
badges.forEach(badge => {
if (totalUnread > 0) {
badge.textContent = totalUnread > 99 ? '99+' : totalUnread;
} else {
badge.textContent = '';
}
});
}
// 导出到 window 供跨模块调用
window.wechatRefreshChatList = refreshChatList;
window.wechatUpdateTabBadge = updateTabBadge;
// 更新"我"页面用户信息
export 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);
}
}
// 切换页面显示
export function showPage(pageId) {
['wechat-main-content', 'wechat-add-page', 'wechat-chat-page', 'wechat-settings-page', 'wechat-me-page', 'wechat-favorites-page', 'wechat-service-page', 'wechat-discover-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 会在 favorites.js 中导出
import('./favorites.js').then(m => m.refreshFavoritesList());
}
if (pageId === 'wechat-service-page') {
const settings = getSettings();
const amountEl = document.getElementById('wechat-wallet-amount');
if (amountEl) {
const amount = settings.walletAmount || '5773.89';
amountEl.textContent = amount.startsWith('¥') ? amount : `¥${amount}`;
}
}
}

316
utils.js Normal file
View File

@@ -0,0 +1,316 @@
/**
* 工具函数
*/
// 获取当前时间字符串
export function getCurrentTime() {
const now = new Date();
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
}
// HTML 转义
export function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 睡眠函数
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 根据内容长度计算语音秒数
export function calculateVoiceDuration(content) {
const seconds = Math.max(2, Math.min(60, Math.ceil(content.length / 3)));
return seconds;
}
// 格式化聊天时间
export 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()}`;
}
}
// 格式化消息时间标签(微信风格)
export 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}`;
}
// 解析时间字符串为时间戳
export 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
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
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: 中文描述
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);
return ts < 10000000000 ? ts * 1000 : ts;
}
// 格式6: Date.parse
const parsed = Date.parse(timeStr);
if (!isNaN(parsed)) {
return parsed;
}
return null;
}
// 解析聊天消息中的微信格式
export 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 allMatches = [];
for (const pattern of patterns) {
pattern.regex.lastIndex = 0;
let match;
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;
}
// 格式化引用日期M.DD 格式)
export function formatQuoteDate(timestamp) {
if (!timestamp) {
const now = new Date();
return `${now.getMonth() + 1}.${now.getDate().toString().padStart(2, '0')}`;
}
const date = new Date(timestamp);
return `${date.getMonth() + 1}.${date.getDate().toString().padStart(2, '0')}`;
}
// 文件转Base64
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function isCatboxFileUrl(url) {
return typeof url === 'string' && /^https?:\/\/files\.catbox\.moe\/[a-z0-9]{6}\.[a-z0-9]+/i.test(url);
}
function withCacheBust(url) {
if (!url || typeof url !== 'string' || url.startsWith('data:')) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${Date.now()}`;
}
function getWeservProxyUrl(url) {
return `https://images.weserv.nl/?url=${encodeURIComponent(url)}`;
}
function toggleReferrerPolicy(imgEl) {
const current = (imgEl?.referrerPolicy || '').toLowerCase();
imgEl.referrerPolicy = current === 'no-referrer' ? '' : 'no-referrer';
}
/**
* 为 <img> 绑定更稳的加载回退:重试 +(仅 catbox代理回退。
* 说明:会在加载失败时自动尝试 cache-bust、切换 referrerPolicy、以及使用 weserv 代理。
*/
export function bindImageLoadFallback(imgEl, options = {}) {
if (!imgEl) return;
const baseSrc = (options.baseSrc ?? imgEl.getAttribute('src') ?? '').toString();
imgEl.dataset.baseSrc = baseSrc;
imgEl.dataset.directRetry = '0';
imgEl.dataset.proxyRetry = '0';
imgEl.dataset.referrerToggled = '0';
imgEl.dataset.proxyUsed = '0';
const maxDirectRetries = Number.isFinite(options.maxDirectRetries) ? options.maxDirectRetries : 2;
const maxProxyRetries = Number.isFinite(options.maxProxyRetries) ? options.maxProxyRetries : 2;
const enableCatboxProxy = options.enableCatboxProxy !== false;
const errorAlt = (options.errorAlt || '加载失败').toString();
const errorStyle = options.errorStyle || { border: '2px solid #ff4d4f' };
const onFail = typeof options.onFail === 'function' ? options.onFail : null;
const markFailed = () => {
imgEl.alt = errorAlt;
if (errorStyle && typeof errorStyle === 'object') {
Object.assign(imgEl.style, errorStyle);
}
onFail?.(baseSrc);
};
imgEl.addEventListener('load', () => {
imgEl.style.border = '';
imgEl.style.padding = '';
imgEl.style.background = '';
});
imgEl.addEventListener('error', () => {
const src = imgEl.dataset.baseSrc || '';
if (!src) return markFailed();
if (src.startsWith('data:')) return markFailed();
const directRetry = parseInt(imgEl.dataset.directRetry || '0', 10) || 0;
const proxyRetry = parseInt(imgEl.dataset.proxyRetry || '0', 10) || 0;
const proxyUsed = imgEl.dataset.proxyUsed === '1';
const referrerToggled = imgEl.dataset.referrerToggled === '1';
if (!proxyUsed) {
if (directRetry < maxDirectRetries) {
imgEl.dataset.directRetry = String(directRetry + 1);
const delay = 400 * (directRetry + 1);
setTimeout(() => {
imgEl.src = withCacheBust(src);
}, delay);
return;
}
if (!referrerToggled) {
imgEl.dataset.referrerToggled = '1';
imgEl.dataset.directRetry = '0';
toggleReferrerPolicy(imgEl);
setTimeout(() => {
imgEl.src = withCacheBust(src);
}, 600);
return;
}
if (enableCatboxProxy && isCatboxFileUrl(src)) {
imgEl.dataset.proxyUsed = '1';
imgEl.dataset.proxyRetry = '0';
imgEl.referrerPolicy = 'no-referrer';
setTimeout(() => {
imgEl.src = withCacheBust(getWeservProxyUrl(src));
}, 700);
return;
}
} else {
if (proxyRetry < maxProxyRetries) {
imgEl.dataset.proxyRetry = String(proxyRetry + 1);
const delay = 600 * (proxyRetry + 1);
setTimeout(() => {
imgEl.src = withCacheBust(getWeservProxyUrl(src));
}, delay);
return;
}
}
markFailed();
});
}

903
video-call.js Normal file
View File

@@ -0,0 +1,903 @@
/**
* 视频通话功能
*/
import { getSettings, splitAIMessages } from './config.js';
import { currentChatIndex } from './chat.js';
import { saveSettingsDebounced } from '../../../../script.js';
import { refreshChatList } from './ui.js';
// 通话状态
let videoCallState = {
isActive: false,
isConnected: false,
isMuted: false,
isCameraOn: true,
startTime: null,
timerInterval: null,
dotsInterval: null,
connectTimeout: null,
contactIndex: -1,
contactName: '',
contactAvatar: '',
messages: [],
contact: null,
initiator: 'user',
rejectedByUser: false
};
// 辅助函数:安全设置头像(避免 onerror 内联处理器问题)
function setAvatarSafe(el, avatarUrl, fallbackChar) {
if (!el) return;
el.innerHTML = '';
if (avatarUrl) {
const img = document.createElement('img');
img.src = avatarUrl;
img.alt = '';
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onerror = () => {
img.remove();
el.textContent = fallbackChar;
};
el.appendChild(img);
} else {
el.textContent = fallbackChar;
}
}
// 开始视频通话
export function startVideoCall(initiator = 'user', contactIndex = currentChatIndex) {
if (videoCallState.isActive) return;
if (contactIndex < 0) return;
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (!contact) return;
videoCallState.contactName = contact.name;
videoCallState.contactAvatar = contact.avatar;
videoCallState.contact = contact;
videoCallState.contactIndex = contactIndex;
videoCallState.isActive = true;
videoCallState.isConnected = false;
videoCallState.isMuted = false;
videoCallState.isCameraOn = true;
videoCallState.messages = [];
videoCallState.initiator = initiator;
videoCallState.rejectedByUser = false;
if (initiator === 'ai') {
showIncomingCallPage();
} else {
showCallPage();
startConnecting();
}
}
// 显示AI来电界面
function showIncomingCallPage() {
const page = document.getElementById('wechat-video-call-page');
const incomingEl = document.getElementById('wechat-video-call-incoming');
if (!page || !incomingEl) return;
// 设置头像和名称
const avatarEl = document.getElementById('wechat-video-call-incoming-avatar');
const nameEl = document.getElementById('wechat-video-call-incoming-name');
const firstChar = videoCallState.contactName ? videoCallState.contactName.charAt(0) : '?';
setAvatarSafe(avatarEl, videoCallState.contactAvatar, firstChar);
if (nameEl) {
nameEl.textContent = videoCallState.contactName;
}
// 隐藏主界面元素,显示来电界面
document.getElementById('wechat-video-call-waiting')?.classList.add('hidden');
document.getElementById('wechat-video-call-chat')?.classList.add('hidden');
document.getElementById('wechat-video-call-actions')?.classList.add('hidden');
incomingEl.classList.remove('hidden');
// 来电阶段不显示计时
const timeEl = document.getElementById('wechat-video-call-time');
if (timeEl) {
timeEl.textContent = '00:00';
timeEl.classList.add('hidden');
}
page.classList.remove('hidden');
bindVideoCallEvents();
// 5秒后如果用户没接就超时
videoCallState.connectTimeout = setTimeout(() => {
if (videoCallState.isActive && !videoCallState.isConnected) {
videoCallState.rejectedByUser = false;
hangupVideoCall();
}
}, 5000);
}
// 显示通话页面
function showCallPage() {
const page = document.getElementById('wechat-video-call-page');
if (!page) return;
// 隐藏来电界面
document.getElementById('wechat-video-call-incoming')?.classList.add('hidden');
// 设置头像 - 使用更安全的方式避免 onerror 内联处理器问题
const avatarEl = document.getElementById('wechat-video-call-avatar');
const remoteAvatarEl = document.getElementById('wechat-video-call-remote-avatar');
const firstChar = videoCallState.contactName ? videoCallState.contactName.charAt(0) : '?';
setAvatarSafe(avatarEl, videoCallState.contactAvatar, firstChar);
setAvatarSafe(remoteAvatarEl, videoCallState.contactAvatar, firstChar);
// 设置本地头像
const localAvatarEl = document.getElementById('wechat-video-call-local-avatar');
if (localAvatarEl) {
try {
const settings = getSettings();
setAvatarSafe(localAvatarEl, settings.userAvatar, '我');
} catch (e) {
localAvatarEl.textContent = '我';
}
}
// 设置名称
const nameEl = document.getElementById('wechat-video-call-name');
if (nameEl) {
nameEl.textContent = videoCallState.contactName;
}
// 设置状态
const statusEl = document.getElementById('wechat-video-call-status');
if (statusEl) {
statusEl.textContent = '等待对方接受邀请';
}
// 显示等待状态
document.getElementById('wechat-video-call-waiting')?.classList.remove('hidden');
document.getElementById('wechat-video-call-actions')?.classList.remove('hidden');
// 重置时间显示
const timeEl = document.getElementById('wechat-video-call-time');
if (timeEl) {
timeEl.textContent = '00:00';
timeEl.classList.add('hidden'); // 拨打中不显示计时
}
// 隐藏对话框
document.getElementById('wechat-video-call-chat')?.classList.add('hidden');
document.getElementById('wechat-video-call-messages')?.innerHTML &&
(document.getElementById('wechat-video-call-messages').innerHTML = '');
// 更新按钮状态
updateCameraButton();
updateMuteButtonVideo();
page.classList.remove('hidden');
bindVideoCallEvents();
}
// 开始连接动画
function startConnecting() {
const statusEl = document.getElementById('wechat-video-call-status');
if (!statusEl) return;
let dotCount = 0;
clearInterval(videoCallState.dotsInterval);
clearTimeout(videoCallState.connectTimeout);
videoCallState.dotsInterval = setInterval(() => {
dotCount = (dotCount + 1) % 4;
const dots = '.'.repeat(dotCount);
statusEl.textContent = '等待对方接受邀请' + dots;
}, 500);
// 用户发起2-4秒后自动接通
const connectDelay = 2000 + Math.random() * 2000;
videoCallState.connectTimeout = setTimeout(() => {
if (videoCallState.isActive && !videoCallState.isConnected) {
onVideoCallConnected();
}
}, connectDelay);
}
// 通话接通
function onVideoCallConnected() {
videoCallState.isConnected = true;
videoCallState.startTime = Date.now();
clearInterval(videoCallState.dotsInterval);
clearTimeout(videoCallState.connectTimeout);
// 隐藏等待状态,显示通话状态
document.getElementById('wechat-video-call-waiting')?.classList.add('hidden');
document.getElementById('wechat-video-call-incoming')?.classList.add('hidden');
document.getElementById('wechat-video-call-actions')?.classList.remove('hidden');
// 显示对话框
document.getElementById('wechat-video-call-chat')?.classList.remove('hidden');
// 接通后才显示计时
const timeEl = document.getElementById('wechat-video-call-time');
timeEl?.classList.remove('hidden');
// 开始计时
startVideoCallTimer();
// 如果是AI发起的通话接通后AI自动发送第一条消息
if (videoCallState.initiator === 'ai') {
triggerAIVideoGreeting();
}
}
// 开始通话计时
function startVideoCallTimer() {
clearInterval(videoCallState.timerInterval);
videoCallState.timerInterval = setInterval(() => {
if (!videoCallState.isConnected || !videoCallState.startTime) return;
const elapsed = Math.floor((Date.now() - videoCallState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
const timeEl = document.getElementById('wechat-video-call-time');
if (timeEl) {
timeEl.textContent = `${minutes}:${seconds}`;
}
}, 1000);
}
// 挂断视频通话
export function hangupVideoCall() {
// 计算通话时长
let durationStr = '00:00';
if (videoCallState.isConnected && videoCallState.startTime) {
const elapsed = Math.floor((Date.now() - videoCallState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
durationStr = `${minutes}:${seconds}`;
}
// 添加通话记录到聊天历史
if (videoCallState.contact) {
const settings = getSettings();
const contact = videoCallState.contact;
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 callContent;
let lastMessage;
if (videoCallState.isConnected) {
callContent = `[视频通话:${durationStr}]`;
lastMessage = `视频通话 ${durationStr}`;
} else {
if (videoCallState.initiator === 'user') {
callContent = '[视频通话:已取消]';
lastMessage = '已取消';
} else if (videoCallState.rejectedByUser) {
callContent = '[视频通话:已拒绝]';
lastMessage = '已拒绝';
} else {
callContent = '[视频通话:对方已取消]';
lastMessage = '对方已取消';
}
}
const callRecord = {
role: videoCallState.initiator === 'user' ? 'user' : 'assistant',
content: callContent,
time: timeStr,
timestamp: Date.now(),
isVideoCallRecord: true
};
contact.chatHistory.push(callRecord);
// 通话内容只进“通话历史”,不在主聊天界面展示(避免污染主界面/列表预览)
if (videoCallState.messages && videoCallState.messages.length > 0) {
const callStatusForHistory = videoCallState.isConnected
? 'connected'
: (videoCallState.initiator === 'user'
? 'cancelled'
: (videoCallState.rejectedByUser ? 'rejected' : 'timeout'));
contact.callHistory = Array.isArray(contact.callHistory) ? contact.callHistory : [];
contact.callHistory.push({
type: 'video',
initiator: videoCallState.initiator,
status: callStatusForHistory,
duration: durationStr,
time: timeStr,
timestamp: Date.now(),
messages: videoCallState.messages.map(m => ({ role: m.role, content: m.content }))
});
}
contact.lastMessage = lastMessage;
// 确定状态类型
let callStatus = 'connected';
if (!videoCallState.isConnected) {
if (videoCallState.initiator === 'user') {
callStatus = 'cancelled';
} else if (videoCallState.rejectedByUser) {
callStatus = 'rejected';
} else {
callStatus = 'timeout';
}
}
if (currentChatIndex === videoCallState.contactIndex) {
appendVideoCallRecordMessage(videoCallState.initiator === 'user' ? 'user' : 'assistant', callStatus, durationStr, contact);
}
// AI 对通话结束做出反应(所有情况都触发)
triggerVideoCallEndReaction(contact, callStatus, videoCallState.initiator, videoCallState.messages);
saveSettingsDebounced();
refreshChatList();
}
videoCallState.isActive = false;
videoCallState.isConnected = false;
videoCallState.startTime = null;
clearInterval(videoCallState.timerInterval);
clearInterval(videoCallState.dotsInterval);
clearTimeout(videoCallState.connectTimeout);
const page = document.getElementById('wechat-video-call-page');
if (page) {
page.classList.add('hidden');
}
}
// 在聊天界面显示视频通话记录消息
function appendVideoCallRecordMessage(role, status, duration, contact) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
let userAvatarContent = '我';
try {
const settings = getSettings();
if (settings.userAvatar) {
userAvatarContent = `<img src="${settings.userAvatar}" alt="" onerror="this.style.display='none';this.parentElement.textContent='我'">`;
}
} catch (e) {}
const avatarContent = role === 'user'
? userAvatarContent
: (contact?.avatar
? `<img src="${contact.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar);
// 摄像机图标
const cameraIconSVG = `<svg class="wechat-call-record-icon wechat-video-call-icon" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="13" height="12" rx="2"/>
<path d="M22 8l-7 4 7 4V8z"/>
</svg>`;
let callRecordHTML;
if (status === 'connected') {
callRecordHTML = `
<div class="wechat-call-record wechat-video-call-record">
<span class="wechat-call-record-text">视频通话 ${duration}</span>
${cameraIconSVG}
</div>
`;
} else if (status === 'cancelled') {
callRecordHTML = `
<div class="wechat-call-record wechat-video-call-record">
<span class="wechat-call-record-text">已取消</span>
${cameraIconSVG}
</div>
`;
} else if (status === 'rejected') {
callRecordHTML = `
<div class="wechat-call-record wechat-video-call-record wechat-call-rejected">
${cameraIconSVG}
<span class="wechat-call-record-text">已拒绝</span>
</div>
`;
} else {
callRecordHTML = `
<div class="wechat-call-record wechat-video-call-record">
${cameraIconSVG}
<span class="wechat-call-record-text">对方已取消</span>
</div>
`;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${callRecordHTML}</div></div>
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 切换摄像头
function toggleCamera() {
videoCallState.isCameraOn = !videoCallState.isCameraOn;
updateCameraButton();
// 摄像头切换时触发AI反应
if (videoCallState.isConnected) {
triggerCameraToggleReaction();
}
}
// 更新摄像头按钮状态
function updateCameraButton() {
const cameraAction = document.getElementById('wechat-video-call-camera');
if (!cameraAction) return;
const btn = cameraAction.querySelector('.wechat-video-call-action-btn');
const label = cameraAction.querySelector('.wechat-video-call-action-label');
if (btn) {
if (videoCallState.isCameraOn) {
btn.classList.remove('off');
} else {
btn.classList.add('off');
}
}
if (label) {
label.textContent = videoCallState.isCameraOn ? '摄像头' : '摄像头已关';
}
// 更新本地小窗显示
const localEl = document.getElementById('wechat-video-call-local');
if (localEl) {
if (videoCallState.isCameraOn) {
localEl.classList.remove('camera-off');
} else {
localEl.classList.add('camera-off');
}
}
}
// 切换静音
function toggleMuteVideo() {
videoCallState.isMuted = !videoCallState.isMuted;
updateMuteButtonVideo();
}
// 更新静音按钮状态
function updateMuteButtonVideo() {
const muteAction = document.getElementById('wechat-video-call-mute');
if (!muteAction) return;
const btn = muteAction.querySelector('.wechat-video-call-action-btn');
const label = muteAction.querySelector('.wechat-video-call-action-label');
if (btn) {
if (videoCallState.isMuted) {
btn.classList.add('muted');
} else {
btn.classList.remove('muted');
}
}
if (label) {
label.textContent = videoCallState.isMuted ? '静音中' : '静音';
}
}
// 绑定事件
let videoEventsBound = false;
function bindVideoCallEvents() {
if (videoEventsBound) return;
videoEventsBound = true;
// 挂断
document.getElementById('wechat-video-call-hangup')?.addEventListener('click', userHangupVideo);
// 静音
document.getElementById('wechat-video-call-mute')?.addEventListener('click', toggleMuteVideo);
// 摄像头
document.getElementById('wechat-video-call-camera')?.addEventListener('click', toggleCamera);
// 最小化
document.getElementById('wechat-video-call-minimize')?.addEventListener('click', userHangupVideo);
// 发送消息
document.getElementById('wechat-video-call-send')?.addEventListener('click', sendVideoCallMessage);
// 输入框回车发送
document.getElementById('wechat-video-call-input')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendVideoCallMessage();
}
});
// AI来电界面事件
document.getElementById('wechat-video-call-incoming-accept')?.addEventListener('click', acceptIncomingCall);
document.getElementById('wechat-video-call-incoming-decline')?.addEventListener('click', declineIncomingCall);
document.getElementById('wechat-video-call-incoming-camera')?.addEventListener('click', toggleIncomingCamera);
}
// 用户主动挂断
function userHangupVideo() {
if (videoCallState.initiator === 'ai' && !videoCallState.isConnected) {
videoCallState.rejectedByUser = true;
}
hangupVideoCall();
}
// 接听来电
function acceptIncomingCall() {
clearTimeout(videoCallState.connectTimeout);
showCallPage();
onVideoCallConnected();
}
// 拒绝来电
function declineIncomingCall() {
videoCallState.rejectedByUser = true;
hangupVideoCall();
}
// 来电界面切换摄像头
let incomingCameraOn = true;
function toggleIncomingCamera() {
incomingCameraOn = !incomingCameraOn;
const btn = document.querySelector('#wechat-video-call-incoming-camera span');
if (btn) {
btn.textContent = incomingCameraOn ? '关闭摄像头' : '打开摄像头';
}
videoCallState.isCameraOn = incomingCameraOn;
}
// AI视频通话开场白
async function triggerAIVideoGreeting() {
if (!videoCallState.isConnected || !videoCallState.contact) return;
// 显示typing指示器
showVideoCallTypingIndicator();
try {
const { callVideoAI } = await import('./ai.js');
const aiResponse = await callVideoAI(
videoCallState.contact,
'[用户接听了视频通话]',
[],
'ai'
);
// 隐藏typing指示器
hideVideoCallTypingIndicator();
const parts = splitAIMessages(aiResponse);
for (const part of parts) {
if (!videoCallState.isConnected) break;
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 分离场景描述和说话内容
// 提取所有括号内的场景描述
const sceneMatches = reply.match(/[^]+/g);
// 移除所有括号内容得到说话部分
const speech = reply.replace(/[^]+/g, '').trim();
// 先发送说话内容
if (speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 400 + Math.random() * 400));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', speech);
}
// 再发送场景描述(合并所有场景)
if (sceneMatches && sceneMatches.length > 0) {
const combinedScene = sceneMatches.join('').replace(//g, '');
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 300));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', combinedScene);
}
// 如果没有括号,直接发送
if (!sceneMatches && !speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 500 + Math.random() * 400));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', reply);
}
}
}
} catch (err) {
hideVideoCallTypingIndicator();
console.error('[可乐] AI视频通话开场白失败:', err);
}
}
// 摄像头切换时AI反应
async function triggerCameraToggleReaction() {
if (!videoCallState.isConnected || !videoCallState.contact) return;
// 显示typing指示器
showVideoCallTypingIndicator();
try {
const { callVideoAI } = await import('./ai.js');
const prompt = videoCallState.isCameraOn
? '[用户重新打开了摄像头,你又可以看到对方了。请对此做出自然的反应,可以观察用户的状态或表情。]'
: '[用户关闭了摄像头,你现在看不到对方了。请对此做出自然的反应,可以表示好奇、调侃或撒娇。]';
const historyMessages = videoCallState.messages.slice(-10);
const aiResponse = await callVideoAI(
videoCallState.contact,
prompt,
historyMessages,
videoCallState.initiator
);
// 隐藏typing指示器
hideVideoCallTypingIndicator();
const parts = splitAIMessages(aiResponse);
for (const part of parts) {
if (!videoCallState.isConnected) break;
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 分离场景描述和说话内容
const sceneMatches = reply.match(/[^]+/g);
const speech = reply.replace(/[^]+/g, '').trim();
if (speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 300));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', speech);
}
if (sceneMatches && sceneMatches.length > 0) {
const combinedScene = sceneMatches.join('').replace(//g, '');
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 200 + Math.random() * 200));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', combinedScene);
}
if (!sceneMatches && !speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 400));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', reply);
}
}
}
} catch (err) {
hideVideoCallTypingIndicator();
console.error('[可乐] 摄像头切换AI反应失败:', err);
}
}
// AI 对视频通话结束做出反应
async function triggerVideoCallEndReaction(contact, callStatus, initiator, callMessages = []) {
if (!contact) return;
let reactionPrompt;
if (callStatus === 'cancelled') {
reactionPrompt = '[用户刚才给你打了视频通话但还没等你接就取消了。请对此做出自然的反应可以表示疑惑或好奇。回复1-2句话即可简短自然。]';
} else if (callStatus === 'rejected') {
reactionPrompt = '[你刚才给用户打视频通话但用户直接挂断拒接了。请对此做出自然的反应可以表示失落或委屈。回复1-2句话即可简短自然。]';
} else if (callStatus === 'timeout') {
reactionPrompt = '[你刚才给用户打视频通话但用户没有接听。请对此做出自然的反应可以表示担心或疑惑。回复1-2句话即可简短自然。]';
} else if (callStatus === 'connected') {
// 已接通的视频通话正常结束
if (callMessages && callMessages.length > 0) {
const lastMessages = callMessages.slice(-5).map(m => `${m.role === 'user' ? '用户' : '你'}: ${m.content}`).join('\n');
reactionPrompt = `[你们刚才视频通话结束了。通话最后几句话是:\n${lastMessages}\n\n请对视频通话结束做出自然的反应可以是对通话内容的总结、表达挂断后的心情、期待下次视频等。回复1-2句话即可简短自然不要复述通话内容。]`;
} else {
reactionPrompt = '[你们刚才视频通话结束了。请对通话结束做出自然的反应可以表达挂断后的心情或期待下次视频。回复1-2句话即可简短自然。]';
}
} else {
return;
}
try {
const { callAI } = await import('./ai.js');
const { appendMessage, showTypingIndicator, hideTypingIndicator } = await import('./chat.js');
const shouldRenderInChat = currentChatIndex === videoCallState.contactIndex;
// 只在当前聊天界面显示 typing/气泡,避免串到别的聊天
if (shouldRenderInChat) {
showTypingIndicator(contact);
}
const aiResponse = await callAI(contact, reactionPrompt);
if (shouldRenderInChat) {
hideTypingIndicator();
}
const parts = splitAIMessages(aiResponse);
for (const part of parts) {
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 保存到聊天历史
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')}`;
if (!contact.chatHistory) contact.chatHistory = [];
contact.chatHistory.push({
role: 'assistant',
content: reply,
time: timeStr,
timestamp: Date.now()
});
contact.lastMessage = reply;
if (shouldRenderInChat) {
// 显示到UI
appendMessage('assistant', reply, contact);
} else {
contact.unreadCount = (contact.unreadCount || 0) + 1;
}
await new Promise(r => setTimeout(r, 600 + Math.random() * 400));
}
}
saveSettingsDebounced();
refreshChatList();
} catch (err) {
console.error('[可乐] AI视频通话结束反应失败:', err);
}
}
// 发送视频通话中消息
async function sendVideoCallMessage() {
const input = document.getElementById('wechat-video-call-input');
if (!input) return;
const message = input.value.trim();
if (!message) return;
if (!videoCallState.isConnected) return;
input.value = '';
addVideoCallMessage('user', message);
// 显示typing指示器
showVideoCallTypingIndicator();
try {
const { callVideoAI } = await import('./ai.js');
const historyMessages = videoCallState.messages.slice(0, -1);
const aiResponse = await callVideoAI(videoCallState.contact, message, historyMessages, videoCallState.initiator);
// 隐藏typing指示器
hideVideoCallTypingIndicator();
const parts = aiResponse.split(/\s*\|\|\|\s*/).filter(Boolean);
for (const part of parts) {
if (!videoCallState.isConnected) break;
let reply = part.trim();
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 分离场景描述和说话内容
const sceneMatches = reply.match(/[^]+/g);
const speech = reply.replace(/[^]+/g, '').trim();
if (speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 400));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', speech);
}
if (sceneMatches && sceneMatches.length > 0) {
const combinedScene = sceneMatches.join('').replace(//g, '');
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 200 + Math.random() * 300));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', combinedScene);
}
if (!sceneMatches && !speech) {
showVideoCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 500));
hideVideoCallTypingIndicator();
if (videoCallState.isConnected) addVideoCallMessage('ai', reply);
}
}
}
} catch (err) {
hideVideoCallTypingIndicator();
console.error('[可乐] 视频通话消息AI回复失败:', err);
}
}
// 显示视频通话中的typing指示器
function showVideoCallTypingIndicator() {
const messagesEl = document.getElementById('wechat-video-call-messages');
if (!messagesEl) return;
// 移除已有的typing指示器
hideVideoCallTypingIndicator();
const typingDiv = document.createElement('div');
typingDiv.className = 'wechat-video-call-msg ai typing-indicator fade-in';
typingDiv.id = 'wechat-video-call-typing';
typingDiv.innerHTML = `
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
`;
messagesEl.appendChild(typingDiv);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// 隐藏视频通话中的typing指示器
function hideVideoCallTypingIndicator() {
const typingEl = document.getElementById('wechat-video-call-typing');
if (typingEl) {
typingEl.remove();
}
}
// 添加视频通话消息
function addVideoCallMessage(role, content) {
const messagesEl = document.getElementById('wechat-video-call-messages');
if (!messagesEl) return;
videoCallState.messages.push({ role, content });
const msgDiv = document.createElement('div');
msgDiv.className = `wechat-video-call-msg ${role} fade-in`;
msgDiv.textContent = content;
messagesEl.appendChild(msgDiv);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 初始化
export function initVideoCall() {
// 事件绑定将在显示页面时进行
}

842
voice-call.js Normal file
View File

@@ -0,0 +1,842 @@
/**
* 语音通话功能
*/
import { getSettings, splitAIMessages } from './config.js';
import { currentChatIndex } from './chat.js';
import { saveSettingsDebounced } from '../../../../script.js';
import { refreshChatList } from './ui.js';
// 通话状态
let callState = {
isActive: false,
isConnected: false,
isMuted: false,
isSpeakerOn: false,
startTime: null,
timerInterval: null,
dotsInterval: null,
connectTimeout: null, // 连接超时计时器
contactIndex: -1,
contactName: '',
contactAvatar: '',
messages: [], // 通话中的消息
contact: null,
initiator: 'user', // 谁发起的通话: 'user' 或 'ai'
rejectedByUser: false // 是否被用户主动拒绝
};
// 开始语音通话
export function startVoiceCall(initiator = 'user', contactIndex = currentChatIndex) {
if (callState.isActive) return;
if (contactIndex < 0) return;
const settings = getSettings();
const contact = settings.contacts[contactIndex];
if (!contact) return;
callState.contactName = contact.name;
callState.contactAvatar = contact.avatar;
callState.contact = contact;
callState.contactIndex = contactIndex;
callState.isActive = true;
callState.isConnected = false;
callState.isMuted = false;
callState.isSpeakerOn = false;
callState.messages = []; // 重置消息
callState.initiator = initiator; // 记录谁发起的通话
callState.rejectedByUser = false; // 重置拒绝状态
showCallPage();
startConnecting();
}
// 显示通话页面
function showCallPage() {
const page = document.getElementById('wechat-voice-call-page');
if (!page) return;
// 设置头像
const avatarEl = document.getElementById('wechat-voice-call-avatar');
if (avatarEl) {
const firstChar = callState.contactName ? callState.contactName.charAt(0) : '?';
if (callState.contactAvatar) {
avatarEl.innerHTML = `<img src="${callState.contactAvatar}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${firstChar}'">`;
} else {
avatarEl.textContent = firstChar;
}
}
// 设置名称
const nameEl = document.getElementById('wechat-voice-call-name');
if (nameEl) {
nameEl.textContent = callState.contactName;
}
// 设置状态 - 根据发起者显示不同文案
const statusEl = document.getElementById('wechat-voice-call-status');
if (statusEl) {
if (callState.initiator === 'ai') {
statusEl.textContent = '邀请你语音通话...';
} else {
statusEl.textContent = '等待对方接受邀请';
}
statusEl.classList.add('connecting');
}
// 重置时间显示 - 等待时隐藏
const timeEl = document.getElementById('wechat-voice-call-time');
if (timeEl) {
timeEl.textContent = '00:00';
timeEl.classList.add('hidden');
}
// 重置按钮状态
updateMuteButton();
updateSpeakerButton();
// 隐藏对话框并清空消息
const chatEl = document.getElementById('wechat-voice-call-chat');
if (chatEl) {
chatEl.classList.add('hidden');
}
const messagesEl = document.getElementById('wechat-voice-call-messages');
if (messagesEl) {
messagesEl.innerHTML = '';
}
// 根据发起者显示不同的操作按钮
const incomingActionsEl = document.getElementById('wechat-voice-call-incoming-actions');
const callActionsEl = document.getElementById('wechat-voice-call-actions');
if (callState.initiator === 'ai') {
// AI发起的来电显示接听/拒绝按钮
if (incomingActionsEl) incomingActionsEl.classList.remove('hidden');
if (callActionsEl) callActionsEl.classList.add('hidden');
} else {
// 用户发起的呼叫:显示静音/挂断/扬声器按钮
if (incomingActionsEl) incomingActionsEl.classList.add('hidden');
if (callActionsEl) callActionsEl.classList.remove('hidden');
}
page.classList.remove('hidden');
bindCallEvents();
}
// 开始连接动画
function startConnecting() {
const statusEl = document.getElementById('wechat-voice-call-status');
if (!statusEl) return;
let dotCount = 0;
clearInterval(callState.dotsInterval);
clearTimeout(callState.connectTimeout);
// 根据发起者显示不同的等待文案
const waitingText = callState.initiator === 'ai' ? '邀请你语音通话' : '等待对方接受邀请';
callState.dotsInterval = setInterval(() => {
dotCount = (dotCount + 1) % 4;
const dots = '.'.repeat(dotCount);
statusEl.textContent = waitingText + dots;
}, 500);
if (callState.initiator === 'user') {
// 用户发起2-4秒后自动接通
const connectDelay = 2000 + Math.random() * 2000;
callState.connectTimeout = setTimeout(() => {
if (callState.isActive && !callState.isConnected) {
onCallConnected();
}
}, connectDelay);
} else {
// AI发起15秒后如果用户没接就超时取消
callState.connectTimeout = setTimeout(() => {
if (callState.isActive && !callState.isConnected) {
// 超时,对方已取消(不是用户主动拒绝)
callState.rejectedByUser = false;
hangupCall();
}
}, 15000);
}
}
// 通话接通
function onCallConnected() {
callState.isConnected = true;
callState.startTime = Date.now();
clearInterval(callState.dotsInterval);
clearTimeout(callState.connectTimeout);
const statusEl = document.getElementById('wechat-voice-call-status');
if (statusEl) {
statusEl.textContent = '通话中';
statusEl.classList.remove('connecting');
}
// 显示计时器
const timeEl = document.getElementById('wechat-voice-call-time');
if (timeEl) {
timeEl.classList.remove('hidden');
}
// 显示对话框
const chatEl = document.getElementById('wechat-voice-call-chat');
if (chatEl) {
chatEl.classList.remove('hidden');
}
// 切换到通话中按钮(隐藏来电按钮,显示通话控制按钮)
const incomingActionsEl = document.getElementById('wechat-voice-call-incoming-actions');
const callActionsEl = document.getElementById('wechat-voice-call-actions');
if (incomingActionsEl) incomingActionsEl.classList.add('hidden');
if (callActionsEl) callActionsEl.classList.remove('hidden');
// 开始计时
startCallTimer();
// 如果是AI发起的通话接通后AI自动发送第一条消息
if (callState.initiator === 'ai') {
triggerAIGreeting();
}
}
// 开始通话计时
function startCallTimer() {
clearInterval(callState.timerInterval);
callState.timerInterval = setInterval(() => {
if (!callState.isConnected || !callState.startTime) return;
const elapsed = Math.floor((Date.now() - callState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
const timeEl = document.getElementById('wechat-voice-call-time');
if (timeEl) {
timeEl.textContent = `${minutes}:${seconds}`;
}
}, 1000);
}
// 挂断电话
export function hangupCall() {
// 计算通话时长
let durationStr = '00:00';
if (callState.isConnected && callState.startTime) {
const elapsed = Math.floor((Date.now() - callState.startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
durationStr = `${minutes}:${seconds}`;
}
// 添加通话记录到聊天历史
if (callState.contact) {
const settings = getSettings();
const contact = callState.contact;
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 callContent;
let lastMessage;
if (callState.isConnected) {
// 已接通的通话
callContent = `[通话记录:${durationStr}]`;
lastMessage = `通话时长 ${durationStr}`;
} else {
// 未接通的通话
if (callState.initiator === 'user') {
// 用户发起,用户取消
callContent = '[通话记录:已取消]';
lastMessage = '已取消';
} else if (callState.rejectedByUser) {
// AI发起用户主动拒绝
callContent = '[通话记录:已拒绝]';
lastMessage = '已拒绝';
} else {
// AI发起超时未接对方取消
callContent = '[通话记录:对方已取消]';
lastMessage = '对方已取消';
}
}
// 通话记录消息
const callRecord = {
role: callState.initiator === 'user' ? 'user' : 'assistant',
content: callContent,
time: timeStr,
timestamp: Date.now(),
isCallRecord: true
};
contact.chatHistory.push(callRecord);
// 通话内容只进“通话历史”,不在主聊天界面展示(避免污染主界面/列表预览)
if (callState.messages && callState.messages.length > 0) {
const callStatusForHistory = callState.isConnected
? 'connected'
: (callState.initiator === 'user'
? 'cancelled'
: (callState.rejectedByUser ? 'rejected' : 'timeout'));
contact.callHistory = Array.isArray(contact.callHistory) ? contact.callHistory : [];
contact.callHistory.push({
type: 'voice',
initiator: callState.initiator,
status: callStatusForHistory,
duration: durationStr,
time: timeStr,
timestamp: Date.now(),
messages: callState.messages.map(m => ({ role: m.role, content: m.content }))
});
}
contact.lastMessage = lastMessage;
// 在聊天界面显示通话记录
// 传递状态类型: 'connected' | 'cancelled' | 'rejected' | 'timeout'
let callStatus = 'connected';
if (!callState.isConnected) {
if (callState.initiator === 'user') {
callStatus = 'cancelled';
} else if (callState.rejectedByUser) {
callStatus = 'rejected';
} else {
callStatus = 'timeout';
}
}
if (currentChatIndex === callState.contactIndex) {
appendCallRecordMessage(callState.initiator === 'user' ? 'user' : 'assistant', callStatus, durationStr, contact);
}
// AI 对通话结束做出反应(所有情况都触发)
triggerCallEndReaction(contact, callStatus, callState.initiator, callState.messages);
saveSettingsDebounced();
refreshChatList();
}
callState.isActive = false;
callState.isConnected = false;
callState.startTime = null;
clearInterval(callState.timerInterval);
clearInterval(callState.dotsInterval);
const page = document.getElementById('wechat-voice-call-page');
if (page) {
page.classList.add('hidden');
}
}
// 在聊天界面显示通话记录消息
// status: 'connected' | 'cancelled' | 'rejected' | 'timeout'
function appendCallRecordMessage(role, status, duration, contact) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
// 获取用户头像
let userAvatarContent = '我';
try {
const settings = getSettings();
if (settings.userAvatar) {
userAvatarContent = `<img src="${settings.userAvatar}" alt="" onerror="this.style.display='none';this.parentElement.textContent='我'">`;
}
} catch (e) {}
const avatarContent = role === 'user'
? userAvatarContent
: (contact?.avatar
? `<img src="${contact.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar);
// 通话记录卡片内容
// 线条电话图标
const phoneIconSVG = `<svg class="wechat-call-record-icon" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>`;
let callRecordHTML;
if (status === 'connected') {
// 已接通:显示通话时长
callRecordHTML = `
<div class="wechat-call-record">
<span class="wechat-call-record-text">通话时长 ${duration}</span>
${phoneIconSVG}
</div>
`;
} else if (status === 'cancelled') {
// 用户发起未接通:已取消(绿色)
callRecordHTML = `
<div class="wechat-call-record">
<span class="wechat-call-record-text">已取消</span>
${phoneIconSVG}
</div>
`;
} else if (status === 'rejected') {
// AI发起用户主动拒绝深灰色
callRecordHTML = `
<div class="wechat-call-record wechat-call-rejected">
${phoneIconSVG}
<span class="wechat-call-record-text">已拒绝</span>
</div>
`;
} else {
// AI发起超时未接对方已取消绿色
callRecordHTML = `
<div class="wechat-call-record">
${phoneIconSVG}
<span class="wechat-call-record-text">对方已取消</span>
</div>
`;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${callRecordHTML}</div></div>
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 切换静音
function toggleMute() {
callState.isMuted = !callState.isMuted;
updateMuteButton();
}
// 更新静音按钮状态
function updateMuteButton() {
const muteAction = document.getElementById('wechat-voice-call-mute');
if (!muteAction) return;
const btn = muteAction.querySelector('.wechat-voice-call-action-btn');
const label = muteAction.querySelector('.wechat-voice-call-action-label');
if (btn) {
if (callState.isMuted) {
btn.classList.add('muted');
} else {
btn.classList.remove('muted');
}
}
if (label) {
label.textContent = callState.isMuted ? '麦克风已关' : '麦克风已开';
}
}
// 切换扬声器
function toggleSpeaker() {
callState.isSpeakerOn = !callState.isSpeakerOn;
updateSpeakerButton();
}
// 更新扬声器按钮状态
function updateSpeakerButton() {
const speakerAction = document.getElementById('wechat-voice-call-speaker');
if (!speakerAction) return;
const btn = speakerAction.querySelector('.wechat-voice-call-action-btn');
const label = speakerAction.querySelector('.wechat-voice-call-action-label');
if (btn) {
if (callState.isSpeakerOn) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
if (label) {
label.textContent = callState.isSpeakerOn ? '扬声器已开' : '扬声器已关';
}
}
// 绑定事件
let eventsBound = false;
function bindCallEvents() {
if (eventsBound) return;
eventsBound = true;
// 挂断(用户主动点击)
document.getElementById('wechat-voice-call-hangup')?.addEventListener('click', userHangup);
// 静音
document.getElementById('wechat-voice-call-mute')?.addEventListener('click', toggleMute);
// 扬声器
document.getElementById('wechat-voice-call-speaker')?.addEventListener('click', toggleSpeaker);
// 最小化(暂时也是挂断)
document.getElementById('wechat-voice-call-minimize')?.addEventListener('click', userHangup);
// 来电接听按钮
document.getElementById('wechat-voice-call-accept')?.addEventListener('click', acceptIncomingCall);
// 来电拒绝按钮
document.getElementById('wechat-voice-call-reject')?.addEventListener('click', rejectIncomingCall);
// 发送消息
document.getElementById('wechat-voice-call-send')?.addEventListener('click', sendCallMessage);
// 输入框回车发送
document.getElementById('wechat-voice-call-input')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendCallMessage();
}
});
}
// 接听来电
function acceptIncomingCall() {
if (!callState.isActive || callState.isConnected) return;
onCallConnected();
}
// 拒绝来电
function rejectIncomingCall() {
if (!callState.isActive || callState.isConnected) return;
callState.rejectedByUser = true;
hangupCall();
}
// 用户主动挂断
function userHangup() {
// 如果是AI发起且未接通标记为用户主动拒绝
if (callState.initiator === 'ai' && !callState.isConnected) {
callState.rejectedByUser = true;
}
hangupCall();
}
// AI发起通话时的开场白
async function triggerAIGreeting() {
if (!callState.isConnected || !callState.contact) return;
// 显示typing指示器
showCallTypingIndicator();
try {
const { callVoiceAI } = await import('./ai.js');
// AI主动打电话发送一个触发消息让AI开场
const aiResponse = await callVoiceAI(
callState.contact,
'[用户接听了电话]',
[],
'ai'
);
// 隐藏typing指示器
hideCallTypingIndicator();
// 按 ||| 分割,并将特殊标签与文本分离,避免"文字+表情包"混在同一条
const parts = splitAIMessages(aiResponse);
for (const part of parts) {
if (!callState.isConnected) break;
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除语音标记
const voiceMatch = reply.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
reply = voiceMatch[1];
}
// 移除其他特殊标记
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 分离小括号内容和说话内容
// 提取所有括号内的语气描述
const moodMatches = reply.match(/[^]+/g);
// 移除所有括号内容得到说话部分
const speech = reply.replace(/[^]+/g, '').trim();
// 先发送说话内容
if (speech) {
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 400 + Math.random() * 400));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', speech);
}
// 再发送语气描述(合并所有语气)
if (moodMatches && moodMatches.length > 0) {
const combinedMood = moodMatches.join('').replace(//g, '');
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 300));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', combinedMood);
}
// 如果没有括号,直接发送
if (!moodMatches && !speech) {
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 500 + Math.random() * 400));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', reply);
}
}
}
} catch (err) {
hideCallTypingIndicator();
console.error('[可乐] AI通话开场白失败:', err);
}
}
// AI 对通话结束做出反应
async function triggerCallEndReaction(contact, callStatus, initiator, callMessages = []) {
if (!contact) return;
// 构建反应提示
let reactionPrompt;
if (callStatus === 'cancelled') {
// 用户取消了自己发起的通话
reactionPrompt = '[用户刚才给你打了电话但还没等你接就取消了。请对此做出自然的反应可以表示疑惑、好奇或关心问问用户怎么了。回复1-2句话即可简短自然。]';
} else if (callStatus === 'rejected') {
// AI发起的通话被用户拒绝
reactionPrompt = '[你刚才给用户打电话但用户直接挂断拒接了。请对此做出自然的反应可以表示失落、委屈或疑惑。回复1-2句话即可简短自然。]';
} else if (callStatus === 'timeout') {
// AI发起的通话超时未接
reactionPrompt = '[你刚才给用户打电话但用户没有接听。请对此做出自然的反应可以表示担心、疑惑或轻微失落。回复1-2句话即可简短自然。]';
} else if (callStatus === 'connected') {
// 已接通的通话正常结束
// 根据通话内容生成回复
if (callMessages && callMessages.length > 0) {
const lastMessages = callMessages.slice(-5).map(m => `${m.role === 'user' ? '用户' : '你'}: ${m.content}`).join('\n');
reactionPrompt = `[你们刚才通完电话挂断了。通话最后几句话是:\n${lastMessages}\n\n请对通话结束做出自然的反应可以是对通话内容的总结、表达挂断后的心情、期待下次通话等。回复1-2句话即可简短自然不要复述通话内容。]`;
} else {
reactionPrompt = '[你们刚才通完电话挂断了。请对通话结束做出自然的反应可以表达挂断后的心情或期待下次通话。回复1-2句话即可简短自然。]';
}
} else {
return; // 未知状态不处理
}
try {
const { callAI } = await import('./ai.js');
const { appendMessage, showTypingIndicator, hideTypingIndicator } = await import('./chat.js');
const shouldRenderInChat = currentChatIndex === callState.contactIndex;
// 只在当前聊天界面显示 typing/气泡,避免串到别的聊天
if (shouldRenderInChat) {
showTypingIndicator(contact);
}
const aiResponse = await callAI(contact, reactionPrompt);
if (shouldRenderInChat) {
hideTypingIndicator();
}
// 按 ||| 分割,并将特殊标签与文本分离,避免“文字+表情包”混在同一条
const parts = splitAIMessages(aiResponse);
for (const part of parts) {
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除可能的特殊标记
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 保存到聊天历史
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')}`;
if (!contact.chatHistory) contact.chatHistory = [];
contact.chatHistory.push({
role: 'assistant',
content: reply,
time: timeStr,
timestamp: Date.now()
});
contact.lastMessage = reply;
if (shouldRenderInChat) {
// 显示到UI
appendMessage('assistant', reply, contact);
} else {
contact.unreadCount = (contact.unreadCount || 0) + 1;
}
// 每条消息之间稍微延迟
await new Promise(r => setTimeout(r, 600 + Math.random() * 400));
}
}
saveSettingsDebounced();
refreshChatList();
} catch (err) {
console.error('[可乐] AI通话结束反应失败:', err);
}
}
// 发送通话中消息
async function sendCallMessage() {
const input = document.getElementById('wechat-voice-call-input');
if (!input) return;
const message = input.value.trim();
if (!message) return;
if (!callState.isConnected) return;
input.value = '';
// 添加用户消息
addCallMessage('user', message);
// 显示typing指示器
showCallTypingIndicator();
// 调用通话专用AI
try {
const { callVoiceAI } = await import('./ai.js');
// 传入通话中的历史消息(不包含刚添加的用户消息)
const historyMessages = callState.messages.slice(0, -1);
// 传递通话发起者信息
const aiResponse = await callVoiceAI(callState.contact, message, historyMessages, callState.initiator);
// 隐藏typing指示器
hideCallTypingIndicator();
// 按 ||| 分割成多条消息
const parts = aiResponse.split(/\s*\|\|\|\s*/).filter(Boolean);
for (const part of parts) {
if (!callState.isConnected) break;
// 提取回复
let reply = part.trim();
// 移除语音标记
const voiceMatch = reply.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
reply = voiceMatch[1];
}
// 移除其他特殊标记
reply = reply.replace(/\[.*?\]/g, '').trim();
if (reply) {
// 分离小括号内容和说话内容
// 提取所有括号内的语气描述
const moodMatches = reply.match(/[^]+/g);
// 移除所有括号内容得到说话部分
const speech = reply.replace(/[^]+/g, '').trim();
// 先发送说话内容
if (speech) {
// 显示typing模拟打字延迟
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 400));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', speech);
}
// 再发送语气描述(合并所有语气)
if (moodMatches && moodMatches.length > 0) {
const combinedMood = moodMatches.join('').replace(//g, '');
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 200 + Math.random() * 300));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', combinedMood);
}
// 如果没有括号,直接发送
if (!moodMatches && !speech) {
showCallTypingIndicator();
await new Promise(r => setTimeout(r, 300 + Math.random() * 500));
hideCallTypingIndicator();
if (callState.isConnected) addCallMessage('ai', reply);
}
}
}
} catch (err) {
hideCallTypingIndicator();
console.error('[可乐] 通话消息AI回复失败:', err);
}
}
// 显示通话中的typing指示器
function showCallTypingIndicator() {
const messagesEl = document.getElementById('wechat-voice-call-messages');
if (!messagesEl) return;
// 移除已有的typing指示器
hideCallTypingIndicator();
const typingDiv = document.createElement('div');
typingDiv.className = 'wechat-voice-call-msg ai typing-indicator fade-in';
typingDiv.id = 'wechat-voice-call-typing';
typingDiv.innerHTML = `
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
`;
messagesEl.appendChild(typingDiv);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// 隐藏通话中的typing指示器
function hideCallTypingIndicator() {
const typingEl = document.getElementById('wechat-voice-call-typing');
if (typingEl) {
typingEl.remove();
}
}
// 添加通话消息(带渐入动画,可滚动查看所有记录)
function addCallMessage(role, content) {
const messagesEl = document.getElementById('wechat-voice-call-messages');
if (!messagesEl) return;
// 添加到状态
callState.messages.push({ role, content });
// 创建新消息元素
const msgDiv = document.createElement('div');
msgDiv.className = `wechat-voice-call-msg ${role} fade-in`;
msgDiv.textContent = content;
// 添加新消息
messagesEl.appendChild(msgDiv);
// 滚动到底部
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// 渲染通话消息(初始化用)
function renderCallMessages() {
const messagesEl = document.getElementById('wechat-voice-call-messages');
if (!messagesEl) return;
messagesEl.innerHTML = callState.messages.map(msg => `
<div class="wechat-voice-call-msg ${msg.role}">${escapeHtml(msg.content)}</div>
`).join('');
// 滚动到底部
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// HTML转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 初始化
export function initVoiceCall() {
// 事件绑定将在显示页面时进行
}