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

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

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