Add files via upload

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

594
message-menu.js Normal file
View File

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