mirror of
https://github.com/Cola-Echo/Cola.git
synced 2026-06-05 23:25:51 +00:00
Add files via upload
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
|
||||||
123
character-import.js
Normal file
123
character-import.js
Normal 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
430
chat-background.js
Normal 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
727
chat-func-panel.js
Normal 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();
|
||||||
|
}
|
||||||
532
config.js
Normal file
532
config.js
Normal 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
554
contacts.js
Normal 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
402
emoji-panel.js
Normal 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
1228
favorites.js
Normal file
File diff suppressed because it is too large
Load Diff
2812
group-chat.js
Normal file
2812
group-chat.js
Normal file
File diff suppressed because it is too large
Load Diff
313
history-logs.js
Normal file
313
history-logs.js
Normal 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
6
index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 可乐不加冰 - SillyTavern 插件入口
|
||||||
|
* 说明:为便于维护,实际逻辑在 main.js 内
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './main.js';
|
||||||
5546
index.legacy.js
Normal file
5546
index.legacy.js
Normal file
File diff suppressed because it is too large
Load Diff
12
manifest.json
Normal file
12
manifest.json
Normal 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
594
message-menu.js
Normal 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
2290
moments.js
Normal file
File diff suppressed because it is too large
Load Diff
1302
phone-html.js
Normal file
1302
phone-html.js
Normal file
File diff suppressed because it is too large
Load Diff
214
phone.js
Normal file
214
phone.js
Normal 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
32
settings-ui.js
Normal 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><${tag}></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
143
st-integration.js
Normal 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('[可乐] ✅ 扩展按钮已添加!');
|
||||||
|
}
|
||||||
823
summary.js
Normal file
823
summary.js
Normal 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('请先配置总结API(URL、密钥和模型)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatUrl = apiUrl.replace(/\/$/, '') + '/chat/completions';
|
||||||
|
|
||||||
|
const response = await fetch(chatUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: '你是一个专业的内容分析师,擅长从对话中提取关键信息并生成结构化的世界书条目。' },
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
],
|
||||||
|
temperature: 1,
|
||||||
|
max_tokens: 8196
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error?.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
|
// 解析JSON
|
||||||
|
const parsed = parseJSONResponse(content);
|
||||||
|
if (parsed) return parsed;
|
||||||
|
|
||||||
|
throw new Error('AI返回内容为空或无法解析');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析JSON响应
|
||||||
|
function parseJSONResponse(content) {
|
||||||
|
// 方法1: 直接解析
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(content);
|
||||||
|
if (result.keys && result.content) return result;
|
||||||
|
if (result.entries?.[0]) return result.entries[0];
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 方法2: 移除markdown代码块
|
||||||
|
try {
|
||||||
|
const cleaned = content.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
|
||||||
|
const result = JSON.parse(cleaned);
|
||||||
|
if (result.keys && result.content) return result;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 方法3: 提取JSON部分
|
||||||
|
try {
|
||||||
|
const firstBrace = content.indexOf('{');
|
||||||
|
const lastBrace = content.lastIndexOf('}');
|
||||||
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
||||||
|
const result = JSON.parse(content.substring(firstBrace, lastBrace + 1));
|
||||||
|
if (result.keys && result.content) return result;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 降级方案
|
||||||
|
if (content && content.trim().length > 20) {
|
||||||
|
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
|
||||||
|
return {
|
||||||
|
keys: [...new Set(words)].slice(0, 5),
|
||||||
|
content: content.substring(0, 800).replace(/```[\s\S]*?```/g, '').trim(),
|
||||||
|
comment: '感情记录'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存条目到收藏
|
||||||
|
export function saveEntryToFavorites(entry, cupNumber, lorebookName) {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.selectedLorebooks) settings.selectedLorebooks = [];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
|
||||||
|
|
||||||
|
// 获取用户名和角色名用于替换占位符
|
||||||
|
const context = getContext();
|
||||||
|
const userName = context?.name1 || 'User';
|
||||||
|
// 从世界书名称中提取角色名(格式:【可乐】和xxx的聊天)
|
||||||
|
let charName = lorebookName;
|
||||||
|
if (lorebookName.startsWith(LOREBOOK_NAME_PREFIX) && lorebookName.endsWith(LOREBOOK_NAME_SUFFIX)) {
|
||||||
|
charName = lorebookName.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lorebook = settings.selectedLorebooks.find(lb => lb.name === lorebookName);
|
||||||
|
|
||||||
|
if (!lorebook) {
|
||||||
|
lorebook = {
|
||||||
|
name: lorebookName,
|
||||||
|
addedTime: timeStr,
|
||||||
|
entries: [],
|
||||||
|
enabled: true,
|
||||||
|
fromSummary: true
|
||||||
|
};
|
||||||
|
settings.selectedLorebooks.push(lorebook);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换 {{user}} 和 {{char}} 占位符
|
||||||
|
const processedContent = replacePlaceholders(entry.content || '', userName, charName);
|
||||||
|
const processedKeys = (entry.keys || []).map(key => replacePlaceholders(key, userName, charName));
|
||||||
|
|
||||||
|
const newEntry = {
|
||||||
|
uid: cupNumber - 1,
|
||||||
|
keys: processedKeys,
|
||||||
|
content: processedContent,
|
||||||
|
comment: entry.comment || getCupName(cupNumber),
|
||||||
|
enabled: true,
|
||||||
|
case_sensitive: false,
|
||||||
|
priority: 10,
|
||||||
|
id: cupNumber - 1,
|
||||||
|
addedTime: timeStr
|
||||||
|
};
|
||||||
|
|
||||||
|
lorebook.entries.push(newEntry);
|
||||||
|
lorebook.lastUpdated = timeStr;
|
||||||
|
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
64
toast.js
Normal 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
510
ui.js
Normal 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
316
utils.js
Normal 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
903
video-call.js
Normal 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
842
voice-call.js
Normal 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() {
|
||||||
|
// 事件绑定将在显示页面时进行
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user