From 37e172bfa9a03330190a93ea567e3137cb5689ab Mon Sep 17 00:00:00 2001 From: Cola-Echo Date: Tue, 23 Dec 2025 01:19:53 +0800 Subject: [PATCH] Add files via upload --- ai.js | 123 +- chat-background.js | 6 +- chat-func-panel.js | 242 +++- chat.js | 1118 +++++++++++---- config.js | 189 ++- contacts.js | 59 +- cropper.js | 390 +++++ emoji-panel.js | 12 +- favorites.js | 30 +- group-chat.js | 128 +- group-red-packet.js | 1733 +++++++++++++++++++++++ history-logs.js | 10 +- icons.js | 49 + listen-together.js | 1282 +++++++++++++++++ main.js | 177 ++- message-menu.js | 106 +- moments.js | 410 +++++- music.js | 494 +++++-- phone-html.js | 606 +++++--- phone.js | 8 +- red-packet.js | 572 ++++++++ save-manager.js | 140 ++ settings-ui.js | 4 +- st-integration.js | 2 +- style.css | 3283 ++++++++++++++++++++++++++++++++++++++++++- summary.js | 19 +- toast.js | 20 +- transfer.js | 481 +++++++ utils.js | 33 +- video-call.js | 60 +- voice-call.js | 38 +- 31 files changed, 10783 insertions(+), 1041 deletions(-) create mode 100644 cropper.js create mode 100644 group-red-packet.js create mode 100644 icons.js create mode 100644 listen-together.js create mode 100644 red-packet.js create mode 100644 save-manager.js create mode 100644 transfer.js diff --git a/ai.js b/ai.js index 908058f..ca45671 100644 --- a/ai.js +++ b/ai.js @@ -3,7 +3,7 @@ */ import { getContext } from '../../../extensions.js'; -import { getSettings, getUserStickers, MEME_PROMPT_TEMPLATE } from './config.js'; +import { getSettings, getUserStickers, MEME_PROMPT_TEMPLATE, LISTEN_TOGETHER_PROMPT_TEMPLATE } from './config.js'; import { sleep } from './utils.js'; function normalizeApiBaseUrl(url) { @@ -385,6 +385,7 @@ function buildStickerPrompt(settings) { 可用表情(共${stickers.length}个):${stickerList}${stickers.length > 30 ? '...' : ''} - 表情消息必须单独一条,用 ||| 分隔 - 适度使用,不要每条都发表情 +- 【绝对禁止】只能使用上面列表中的名称或序号!必须完全一致!禁止自己编造、修改、添加后缀! 示例:好的呀|||[表情:开心] `; } @@ -667,7 +668,16 @@ export function buildMessages(contact, userMessage) { }); }); - messages.push({ role: 'user', content: userMessage }); + // 检查是否需要添加用户消息(避免重复) + // 如果最后一条消息已经是相同的用户消息,就不再重复添加 + const lastAddedMsg = messages[messages.length - 1]; + const isAlreadyAdded = lastAddedMsg && + lastAddedMsg.role === 'user' && + lastAddedMsg.content === userMessage; + + if (!isAlreadyAdded) { + messages.push({ role: 'user', content: userMessage }); + } return messages; } @@ -958,7 +968,7 @@ export async function callVideoAI(contact, userMessage, callMessages = [], initi - 一般输出2-4句话 - 用小括号描述画面场景,这是用户看到的视频画面 - 【禁止】括号内不准使用任何人称代词(你、我、她、他)!这是摄像头视角的画面描述! -- 【禁止】视频通话中不要使用任何表情包格式如 [表情:xxx],直接说话和描述动作即可 +- 【禁止】视频通话中不要使用任何表情包格式,包括 [表情:xxx] 和 xxx,直接说话和描述动作即可 - 括号内只描述画面:人物动作、表情、背景、光线等 【正确示例 - 注意 ||| 分隔符】 @@ -987,7 +997,7 @@ export async function callVideoAI(contact, userMessage, callMessages = [], initi - 一般输出2-4句话 - 用小括号描述画面场景,这是用户看到的视频画面 - 【禁止】括号内不准使用任何人称代词(你、我、她、他)!这是摄像头视角的画面描述! -- 【禁止】视频通话中不要使用任何表情包格式如 [表情:xxx],直接说话和描述动作即可 +- 【禁止】视频通话中不要使用任何表情包格式,包括 [表情:xxx] 和 xxx,直接说话和描述动作即可 - 括号内只描述画面:人物动作、表情、背景、光线等 【正确示例 - 注意 ||| 分隔符】 @@ -1082,3 +1092,108 @@ ${videoCallPrompt}`; const data = await response.json(); return data.choices?.[0]?.message?.content || '...'; } + +// 一起听场景中调用 AI(使用专门的一起听提示词,只允许纯文字回复) +export async function callListenTogetherAI(contact, userMessage, listenMessages = [], song = null) { + // 获取 API 配置 + let apiUrl, apiKey, apiModel; + + if (contact.useCustomApi) { + apiUrl = contact.customApiUrl || ''; + apiKey = contact.customApiKey || ''; + apiModel = contact.customModel || ''; + + const globalConfig = getApiConfig(); + if (!apiUrl) apiUrl = globalConfig.url; + if (!apiKey) apiKey = globalConfig.key; + if (!apiModel) apiModel = globalConfig.model; + } else { + const globalConfig = getApiConfig(); + apiUrl = globalConfig.url; + apiKey = globalConfig.key; + apiModel = globalConfig.model; + } + + if (!apiUrl) { + throw new Error('请先配置 API 地址'); + } + + if (!apiModel) { + throw new Error('请先选择模型'); + } + + // 构建一起听专用的提示词(替换歌曲信息占位符) + let listenPrompt = LISTEN_TOGETHER_PROMPT_TEMPLATE + .replace('{{song_name}}', song?.name || '未知歌曲') + .replace('{{song_artist}}', song?.artist || '未知歌手'); + + // 构建系统提示词(在原有角色设定基础上添加一起听场景,禁用表情包/音乐分享/通话请求) + const baseSystemPrompt = buildSystemPrompt(contact, { allowStickers: false, allowMusicShare: false, allowCallRequests: false }); + const systemPrompt = `${baseSystemPrompt} + +【当前场景:一起听歌中】 +${listenPrompt}`; + + // 构建消息 + const messages = [{ role: 'system', content: systemPrompt }]; + + // 添加聊天历史记录(最近10条) + const chatHistory = contact.chatHistory || []; + const recentHistory = chatHistory.slice(-10); + recentHistory.forEach(msg => { + if (msg.isRecalled) { + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: '[用户撤回了一条消息]' + }); + return; + } + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + }); + }); + + // 添加一起听开始标记 + messages.push({ role: 'user', content: `[用户邀请你一起听歌:《${song?.name || '未知歌曲'}》- ${song?.artist || '未知歌手'}]` }); + + // 添加一起听中的历史消息 + listenMessages.forEach(msg => { + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + }); + }); + + // 添加当前消息 + messages.push({ role: 'user', content: userMessage }); + + const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions'; + + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetchWithRetry( + chatUrl, + { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: apiModel, + messages: messages, + temperature: 0.9, + max_tokens: 1024 + }) + }, + { maxRetries: 3 } + ); + + if (!response.ok) { + throw new Error(await formatApiError(response, {})); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content || '...'; +} diff --git a/chat-background.js b/chat-background.js index c42e7f5..9d5bbd1 100644 --- a/chat-background.js +++ b/chat-background.js @@ -3,7 +3,7 @@ * 支持每个联系人独立设置背景,含图片裁剪功能 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave } from './save-manager.js'; import { getSettings } from './config.js'; import { showToast } from './toast.js'; import { currentChatIndex } from './chat.js'; @@ -375,7 +375,7 @@ function saveChatBackground(imageData) { } contact.chatBackground = imageData; - saveSettingsDebounced(); + requestSave(); // 立即应用背景 applyChatBackground(imageData); @@ -390,7 +390,7 @@ function clearChatBackground() { if (!contact) return; delete contact.chatBackground; - saveSettingsDebounced(); + requestSave(); // 清除背景 applyChatBackground(null); diff --git a/chat-func-panel.js b/chat-func-panel.js index 056b202..30bca39 100644 --- a/chat-func-panel.js +++ b/chat-func-panel.js @@ -9,10 +9,13 @@ import { isInGroupChat, sendGroupMessage, sendGroupPhotoMessage, sendGroupBatchM import { startVoiceCall } from './voice-call.js'; import { startVideoCall } from './video-call.js'; import { showMusicPanel, initMusicEvents } from './music.js'; +import { showRedPacketPage } from './red-packet.js'; +import { showTransferPage } from './transfer.js'; import { getSettings, splitAIMessages } from './config.js'; import { refreshChatList } from './ui.js'; -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave } from './save-manager.js'; import { callAI } from './ai.js'; +import { showListenSearchPage, initListenTogether } from './listen-together.js'; let expandMode = null; // 'voice' | 'multi' | null // 混合消息项: { type: 'text' | 'voice' | 'sticker' | 'photo', content: string } @@ -106,7 +109,7 @@ function initMusicShareListener() { groupChat.lastMessage = `[音乐] ${name}`; groupChat.lastMessageTime = Date.now(); - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 获取成员信息 @@ -154,7 +157,7 @@ function initMusicShareListener() { groupChat.lastMessageTime = Date.now(); } - saveSettingsDebounced(); + requestSave(); refreshChatList(); } catch (err) { hideGroupTypingIndicator(); @@ -189,7 +192,7 @@ function initMusicShareListener() { }); contact.lastMessage = `[音乐] ${name}`; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 调用AI回复 @@ -235,7 +238,7 @@ function initMusicShareListener() { if (lastShownMessage) { contact.lastMessage = lastShownMessage.length > 20 ? lastShownMessage.substring(0, 20) + '...' : lastShownMessage; } - saveSettingsDebounced(); + requestSave(); refreshChatList(); } } catch (err) { @@ -544,7 +547,7 @@ export async function sendExpandContent() { const content = textarea?.value.trim(); if (!content) { - showToast('请输入语音内容', '🧊'); + showToast('请输入语音内容', 'info'); return; } @@ -562,7 +565,7 @@ export async function sendExpandContent() { const content = textarea?.value.trim(); if (!content) { - showToast('请输入照片描述', '🧊'); + showToast('请输入照片描述', 'info'); return; } @@ -585,7 +588,7 @@ export async function sendExpandContent() { }); if (validMessages.length === 0) { - showToast('请至少输入一条消息', '🧊'); + showToast('请至少输入一条消息', 'info'); return; } @@ -656,8 +659,39 @@ function handleFuncItemClick(func) { hideFuncPanel(); showMusicPanel(); return; + case 'redpacket': + hideFuncPanel(); + if (isInGroupChat()) { + // 群聊红包 - 动态导入 + import('./group-red-packet.js').then(m => m.showGroupRedPacketTypePage()); + } else { + showRedPacketPage(); + } + return; + case 'transfer': + hideFuncPanel(); + if (isInGroupChat()) { + // 群聊转账 - 先选择成员 + import('./group-red-packet.js').then(m => m.showGroupTransferSelectPage()); + } else { + showTransferPage(); + } + return; + case 'time': + hideFuncPanel(); + showTimePicker(); + return; + case 'listen': + hideFuncPanel(); + // 群聊不支持一起听 + if (isInGroupChat()) { + showToast('群聊暂不支持一起听', 'info'); + return; + } + showListenSearchPage(); + return; default: - showToast('该功能开发中...', '🧊'); + showToast('该功能开发中...', 'info'); } } @@ -724,4 +758,194 @@ export function initFuncPanel() { // 初始化音乐面板事件 initMusicEvents(); initMusicShareListener(); + initTimePickerEvents(); + initListenTogether(); +} + +// ============ 时间选择器相关 ============ + +// 存储选择的时间(null 表示使用当前时间) +let selectedTime = null; +let timePickerInited = false; + +// 时间选择器当前选中的值 +let pickerValues = { + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + day: new Date().getDate(), + hour: new Date().getHours(), + minute: new Date().getMinutes(), + second: new Date().getSeconds() +}; + +// 获取选择的时间(供 chat.js 使用) +export function getSelectedTime() { + return selectedTime; +} + +// 清除选择的时间 +export function clearSelectedTime() { + selectedTime = null; + updateTimeIndicator(); +} + +// 显示时间选择器 +function showTimePicker() { + const picker = document.getElementById('wechat-time-picker'); + if (!picker) return; + + // 初始化为当前时间 + const now = new Date(); + pickerValues = { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: now.getHours(), + minute: now.getMinutes(), + second: now.getSeconds() + }; + + renderTimePickerColumns(); + updateTimePickerDisplay(); + picker.classList.remove('hidden'); +} + +// 隐藏时间选择器 +function hideTimePicker() { + const picker = document.getElementById('wechat-time-picker'); + picker?.classList.add('hidden'); +} + +// 渲染时间选择器列 +function renderTimePickerColumns() { + const currentYear = new Date().getFullYear(); + + // 年份:前后5年 + renderPickerColumn('year', currentYear - 5, currentYear + 5, pickerValues.year, '年'); + // 月份:1-12 + renderPickerColumn('month', 1, 12, pickerValues.month, '月'); + // 日期:根据年月动态计算 + const daysInMonth = new Date(pickerValues.year, pickerValues.month, 0).getDate(); + renderPickerColumn('day', 1, daysInMonth, pickerValues.day, '日'); + // 小时:0-23 + renderPickerColumn('hour', 0, 23, pickerValues.hour, '时'); + // 分钟:0-59 + renderPickerColumn('minute', 0, 59, pickerValues.minute, '分'); + // 秒:0-59 + renderPickerColumn('second', 0, 59, pickerValues.second, '秒'); +} + +// 渲染单个列 +function renderPickerColumn(type, min, max, selected, suffix) { + const container = document.getElementById(`wechat-time-picker-${type}`); + if (!container) return; + + let html = ''; + for (let i = min; i <= max; i++) { + const value = type === 'year' ? i : i.toString().padStart(2, '0'); + const isSelected = i === selected; + html += `
${value}${suffix}
`; + } + container.innerHTML = html; + + // 滚动到选中项 + setTimeout(() => { + const selectedItem = container.querySelector('.selected'); + if (selectedItem) { + container.scrollTop = selectedItem.offsetTop - container.offsetHeight / 2 + selectedItem.offsetHeight / 2; + } + }, 0); +} + +// 更新显示的时间 +function updateTimePickerDisplay() { + const display = document.getElementById('wechat-time-picker-display'); + if (!display) return; + + const { year, month, day, hour, minute, second } = pickerValues; + display.textContent = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`; +} + +// 更新输入框旁的时间指示器 +function updateTimeIndicator() { + let indicator = document.getElementById('wechat-time-indicator'); + + if (!selectedTime) { + indicator?.remove(); + return; + } + + if (!indicator) { + const inputArea = document.querySelector('.wechat-chat-input-area'); + if (!inputArea) return; + + indicator = document.createElement('div'); + indicator.id = 'wechat-time-indicator'; + indicator.className = 'wechat-time-indicator'; + inputArea.insertBefore(indicator, inputArea.firstChild); + } + + const date = new Date(selectedTime); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours().toString().padStart(2, '0'); + const minute = date.getMinutes().toString().padStart(2, '0'); + + indicator.innerHTML = ` + ${month}月${day}日 ${hour}:${minute} + + `; + + // 绑定清除按钮 + document.getElementById('wechat-time-indicator-clear')?.addEventListener('click', (e) => { + e.stopPropagation(); + clearSelectedTime(); + }); +} + +// 初始化时间选择器事件 +function initTimePickerEvents() { + if (timePickerInited) return; + timePickerInited = true; + + // 监听列项点击 + document.addEventListener('click', (e) => { + const item = e.target.closest('.wechat-time-picker-item'); + if (!item) return; + + const column = item.closest('.wechat-time-picker-column'); + if (!column) return; + + const type = column.dataset.type; + const value = parseInt(item.dataset.value); + + // 更新选中值 + pickerValues[type] = value; + + // 更新选中样式 + column.querySelectorAll('.wechat-time-picker-item').forEach(el => { + el.classList.toggle('selected', parseInt(el.dataset.value) === value); + }); + + // 如果改变了年或月,需要重新渲染日期列 + if (type === 'year' || type === 'month') { + const daysInMonth = new Date(pickerValues.year, pickerValues.month, 0).getDate(); + if (pickerValues.day > daysInMonth) { + pickerValues.day = daysInMonth; + } + renderPickerColumn('day', 1, daysInMonth, pickerValues.day, '日'); + } + + updateTimePickerDisplay(); + }); + + // 确认按钮 + document.getElementById('wechat-time-picker-confirm')?.addEventListener('click', () => { + const { year, month, day, hour, minute, second } = pickerValues; + selectedTime = new Date(year, month - 1, day, hour, minute, second).getTime(); + + hideTimePicker(); + updateTimeIndicator(); + showToast('已设置发送时间', '⏰'); + }); } diff --git a/chat.js b/chat.js index 8d57f98..1c13ae8 100644 --- a/chat.js +++ b/chat.js @@ -2,18 +2,22 @@ * 聊天功能 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave, saveNow } from './save-manager.js'; import { getContext } from '../../../extensions.js'; import { getSettings, SUMMARY_MARKER_PREFIX, getUserStickers, parseMemeTag, splitAIMessages } from './config.js'; -import { escapeHtml, sleep, formatMessageTime, calculateVoiceDuration, formatQuoteDate, bindImageLoadFallback } from './utils.js'; +import { escapeHtml, sleep, formatMessageTime, calculateVoiceDuration, formatQuoteDate, bindImageLoadFallback, extractEmbeddedPhotos } from './utils.js'; import { getUserAvatarHTML, refreshChatList } from './ui.js'; -import { bindMessageBubbleEvents, getPendingQuote, clearQuote, setQuote } from './message-menu.js'; +import { bindMessageBubbleEvents, getPendingQuote, clearQuote, setQuote, showMessageMenu, hideMessageMenu } from './message-menu.js'; import { showToast, showNotificationBanner } from './toast.js'; +import { ICON_RED_PACKET } from './icons.js'; import { aiShareMusic, playMusic as kugouPlayMusic } from './music.js'; import { loadContactBackground } from './chat-background.js'; import { tryTriggerMomentAfterChat, addMomentToContact } from './moments.js'; import { startVoiceCall } from './voice-call.js'; import { startVideoCall } from './video-call.js'; +import { showOpenRedPacket, generateRedPacketId } from './red-packet.js'; +import { showReceiveTransferPage, generateTransferId } from './transfer.js'; +import { getSelectedTime, clearSelectedTime } from './chat-func-panel.js'; // 当前聊天的联系人索引 export let currentChatIndex = -1; @@ -21,6 +25,11 @@ export let currentChatIndex = -1; // 聊天记录上限(达到此数量时提醒总结) const CHAT_HISTORY_LIMIT = 300; +// 分页渲染配置 +const MESSAGES_PER_PAGE = 80; +let currentRenderedStartIndex = 0; // 当前渲染的起始索引 +let isLoadingMoreMessages = false; // 是否正在加载更多消息 + // 检测AI发起通话请求的类型 // 返回 'voice' | 'video' | null(仅用于精确匹配) export function detectAiCallRequest(message) { @@ -71,18 +80,56 @@ const detectAiCallRequestType = detectAiCallRequest; // 检查聊天记录是否需要总结(单聊) export function checkSummaryReminder(contact) { if (!contact || !contact.chatHistory) return; - const count = contact.chatHistory.length; - if (count >= CHAT_HISTORY_LIMIT) { - showToast(`聊天记录已达${count}条,建议总结`, '⚠️', 4000); + + // 查找最后一个总结标记的位置 + let lastMarkerIndex = -1; + for (let i = contact.chatHistory.length - 1; i >= 0; i--) { + if (contact.chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX) || contact.chatHistory[i].isMarker) { + lastMarkerIndex = i; + break; + } + } + + // 计算标记之后的消息数量(不含标记本身) + const newMsgCount = contact.chatHistory.slice(lastMarkerIndex + 1).filter( + m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX) && !m.isMarker + ).length; + + // 只在刚好达到阈值时提醒一次(通过标记位避免重复提醒) + if (newMsgCount >= CHAT_HISTORY_LIMIT && !contact._summaryReminderShown) { + contact._summaryReminderShown = true; + showToast(`聊天记录已达${newMsgCount}条,建议总结`, '⚠️', 2500); + } else if (newMsgCount < CHAT_HISTORY_LIMIT) { + // 如果消息数低于阈值(可能是总结后),重置标记 + contact._summaryReminderShown = false; } } // 检查群聊记录是否需要总结 export function checkGroupSummaryReminder(groupChat) { if (!groupChat || !groupChat.chatHistory) return; - const count = groupChat.chatHistory.length; - if (count >= CHAT_HISTORY_LIMIT) { - showToast(`群聊记录已达${count}条,建议总结`, '⚠️', 4000); + + // 查找最后一个总结标记的位置 + let lastMarkerIndex = -1; + for (let i = groupChat.chatHistory.length - 1; i >= 0; i--) { + if (groupChat.chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX) || groupChat.chatHistory[i].isMarker) { + lastMarkerIndex = i; + break; + } + } + + // 计算标记之后的消息数量(不含标记本身) + const newMsgCount = groupChat.chatHistory.slice(lastMarkerIndex + 1).filter( + m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX) && !m.isMarker + ).length; + + // 只在刚好达到阈值时提醒一次(通过标记位避免重复提醒) + if (newMsgCount >= CHAT_HISTORY_LIMIT && !groupChat._summaryReminderShown) { + groupChat._summaryReminderShown = true; + showToast(`群聊记录已达${newMsgCount}条,建议总结`, '⚠️', 2500); + } else if (newMsgCount < CHAT_HISTORY_LIMIT) { + // 如果消息数低于阈值(可能是总结后),重置标记 + groupChat._summaryReminderShown = false; } } @@ -278,7 +325,7 @@ export function openChat(contactIndex) { // 清除未读消息计数 if (contact.unreadCount && contact.unreadCount > 0) { contact.unreadCount = 0; - saveSettingsDebounced(); + requestSave(); refreshChatList(); } @@ -291,12 +338,29 @@ export function openChat(contactIndex) { if (chatHistory.length === 0) { messagesContainer.innerHTML = ''; + currentRenderedStartIndex = 0; } else { - messagesContainer.innerHTML = renderChatHistory(contact, chatHistory); + // 分页渲染:只渲染最后 MESSAGES_PER_PAGE 条消息 + const totalMessages = chatHistory.length; + currentRenderedStartIndex = Math.max(0, totalMessages - MESSAGES_PER_PAGE); + const messagesToRender = chatHistory.slice(currentRenderedStartIndex); + + // 如果有更多历史消息,显示"加载更多"提示 + let loadMoreHtml = ''; + if (currentRenderedStartIndex > 0) { + loadMoreHtml = `
上滑加载更多消息 (${currentRenderedStartIndex} 条)
`; + } + + messagesContainer.innerHTML = loadMoreHtml + renderChatHistory(contact, messagesToRender, currentRenderedStartIndex); bindVoiceBubbleEvents(messagesContainer); bindPhotoBubbleEvents(messagesContainer); bindMusicCardEvents(messagesContainer); bindMessageBubbleEvents(messagesContainer); + bindRedPacketBubbleEvents(messagesContainer); + bindTransferBubbleEvents(messagesContainer); + + // 绑定滚动加载更多事件 + bindScrollLoadMore(messagesContainer, contact); } messagesContainer.scrollTop = messagesContainer.scrollHeight; @@ -320,18 +384,99 @@ export function openChatByContactId(contactId, index) { } } +// 绑定滚动加载更多事件 +function bindScrollLoadMore(container, contact) { + // 移除旧的事件监听器(如果有) + container.removeEventListener('scroll', container._scrollHandler); + + container._scrollHandler = function() { + // 如果正在加载或已经加载完所有消息,不处理 + if (isLoadingMoreMessages || currentRenderedStartIndex <= 0) return; + + // 当滚动到顶部附近时(距离顶部小于100px)加载更多 + if (container.scrollTop < 100) { + loadMoreMessages(container, contact); + } + }; + + container.addEventListener('scroll', container._scrollHandler); +} + +// 加载更多历史消息 +function loadMoreMessages(container, contact) { + if (isLoadingMoreMessages || currentRenderedStartIndex <= 0) return; + + isLoadingMoreMessages = true; + + const chatHistory = contact.chatHistory || []; + + // 计算要加载的消息范围 + const newEndIndex = currentRenderedStartIndex; + const newStartIndex = Math.max(0, currentRenderedStartIndex - MESSAGES_PER_PAGE); + const messagesToLoad = chatHistory.slice(newStartIndex, newEndIndex); + + if (messagesToLoad.length === 0) { + isLoadingMoreMessages = false; + return; + } + + // 保存当前滚动位置 + const oldScrollHeight = container.scrollHeight; + + // 渲染新消息 + const newHtml = renderChatHistory(contact, messagesToLoad, newStartIndex); + + // 更新"加载更多"提示 + const loadMoreEl = document.getElementById('wechat-load-more'); + if (loadMoreEl) { + if (newStartIndex > 0) { + loadMoreEl.textContent = `上滑加载更多消息 (${newStartIndex} 条)`; + loadMoreEl.insertAdjacentHTML('afterend', newHtml); + } else { + // 已加载所有消息,移除提示 + loadMoreEl.insertAdjacentHTML('afterend', newHtml); + loadMoreEl.remove(); + } + } + + // 更新当前渲染的起始索引 + currentRenderedStartIndex = newStartIndex; + + // 绑定新消息的事件 + bindVoiceBubbleEvents(container); + bindPhotoBubbleEvents(container); + bindMusicCardEvents(container); + bindMessageBubbleEvents(container); + + // 恢复滚动位置,使用户看到的内容不变 + const newScrollHeight = container.scrollHeight; + container.scrollTop = newScrollHeight - oldScrollHeight; + + isLoadingMoreMessages = false; + + console.log('[可乐] 加载更多消息:', { + 已加载: messagesToLoad.length, + 剩余: newStartIndex, + 总数: chatHistory.length + }); +} + // 渲染聊天历史 -export function renderChatHistory(contact, chatHistory) { - const firstChar = contact.name ? contact.name.charAt(0) : '?'; - const avatarContent = contact.avatar - ? `` +// indexOffset: 消息在原始 chatHistory 中的起始索引偏移量 +export function renderChatHistory(contact, chatHistory, indexOffset = 0) { + const contactName = (contact?.name || '?').toString(); + const firstChar = escapeHtml(contactName.charAt(0) || '?'); + const avatarContent = contact?.avatar + ? `` : firstChar; let html = ''; let lastTimestamp = 0; const TIME_GAP_THRESHOLD = 5 * 60 * 1000; - chatHistory.forEach((msg, index) => { + chatHistory.forEach((msg, localIndex) => { + // 计算在原始 chatHistory 中的真实索引 + const index = indexOffset + localIndex; const msgTimestamp = msg.timestamp || new Date(msg.time).getTime() || 0; // 跳过通话中的消息(只保存到历史记录,不显示为聊天气泡) @@ -441,16 +586,16 @@ export function renderChatHistory(contact, chatHistory) { // 已接通:显示视频通话时长 videoCallRecordHTML = `
- 视频通话 ${callInfo} ${cameraIconSVG} + 视频通话 ${callInfo}
`; } else if (isCancelled) { // 用户发起未接通:已取消 videoCallRecordHTML = `
- 已取消 ${cameraIconSVG} + 已取消
`; } else if (isRejected) { @@ -473,8 +618,8 @@ export function renderChatHistory(contact, chatHistory) { // 兜底:显示原始内容 videoCallRecordHTML = `
- ${escapeHtml(callInfo)} ${cameraIconSVG} + ${escapeHtml(callInfo)}
`; } @@ -488,6 +633,88 @@ export function renderChatHistory(contact, chatHistory) { return; } + // 检查是否是红包消息 + if (msg.isRedPacket && msg.redPacketInfo) { + const rpInfo = msg.redPacketInfo; + const isClaimed = rpInfo.status === 'claimed'; + // 检查是否过期(未领取且超过24小时) + const isExpired = !isClaimed && rpInfo.expireAt && Date.now() > rpInfo.expireAt; + const claimedClass = isClaimed ? 'claimed' : (isExpired ? 'expired' : ''); + const statusText = isClaimed + ? '已领取' + : (isExpired + ? '已过期' + : ''); + + const rpBubbleHTML = ` +
+
${ICON_RED_PACKET}
+
+
${escapeHtml(rpInfo.message || '恭喜发财,大吉大利')}
+ ${statusText} +
+ +
+ `; + + if (msg.role === 'user') { + html += `
${getUserAvatarHTML()}
${rpBubbleHTML}
`; + } else { + html += `
${avatarContent}
${rpBubbleHTML}
`; + } + lastTimestamp = msgTimestamp; + return; + } + + // 检查是否是转账消息 + if (msg.isTransfer && msg.transferInfo) { + const tfInfo = msg.transferInfo; + let status = tfInfo.status || 'pending'; + + // 检查是否过期(待收款且超过24小时) + const isExpired = status === 'pending' && tfInfo.expireAt && Date.now() > tfInfo.expireAt; + if (isExpired) { + status = 'expired'; + } + + // 状态图标和文字 + let statusIcon, statusText; + if (status === 'received') { + statusIcon = ``; + statusText = '已收款'; + } else if (status === 'refunded' || status === 'expired') { + // 已退还 或 已过期(使用相同图标和文字) + statusIcon = ``; + statusText = msg.role === 'user' ? '已被退还' : '已退还'; + } else { + statusIcon = ``; + statusText = msg.role === 'user' ? '你发起了一笔转账' : '请收款'; + } + + const tfBubbleHTML = ` +
+
¥${tfInfo.amount.toFixed(2)}
+
+ ${statusIcon} + ${statusText} +
+ +
+ `; + + if (msg.role === 'user') { + html += `
${getUserAvatarHTML()}
${tfBubbleHTML}
`; + } else { + html += `
${avatarContent}
${tfBubbleHTML}
`; + } + lastTimestamp = msgTimestamp; + return; + } + if (index === 0 || (msgTimestamp - lastTimestamp > TIME_GAP_THRESHOLD)) { const timeLabel = formatMessageTime(msgTimestamp); if (timeLabel) { @@ -503,8 +730,8 @@ export function renderChatHistory(contact, chatHistory) { // 检查是否包含 ||| 分隔符(历史消息可能未正确分割) // 如果包含,则拆分成多个独立消息,每个都有自己的头像 - const msgContent = msg.content || ''; - if (!isVoice && !isSticker && !isPhoto && !isMusic && (msgContent.indexOf('|||') >= 0 || /<\\s*meme\\s*>/i.test(msgContent))) { + const msgContent = (msg.content || '').toString(); + if (!isVoice && !isSticker && !isPhoto && !isMusic && (msgContent.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(msgContent))) { const parts = (msgContent.indexOf('|||') >= 0 ? msgContent.split('|||').map(function(p) { return p.trim(); }).filter(function(p) { return p; }) : splitAIMessages(msgContent).map(function(p) { return (p || '').toString().trim(); }).filter(function(p) { return p; }) @@ -520,19 +747,20 @@ export function renderChatHistory(contact, chatHistory) { var partQuoteHtml = ''; if (pi === 0 && msg.quote) { var quoteText; + var quoteContent = (msg.quote.content || '').toString(); if (msg.quote.isVoice) { - var seconds = Math.max(2, Math.min(60, Math.ceil((msg.quote.content || '').length / 3))); + var seconds = Math.max(2, Math.min(60, Math.ceil(quoteContent.length / 3))); quoteText = '[语音] ' + seconds + '"'; } else if (msg.quote.isPhoto) { quoteText = '[照片]'; } else if (msg.quote.isSticker) { quoteText = '[表情]'; } else { - quoteText = msg.quote.content.length > 30 - ? msg.quote.content.substring(0, 30) + '...' - : msg.quote.content; + quoteText = quoteContent.length > 30 + ? quoteContent.substring(0, 30) + '...' + : quoteContent; } - partQuoteHtml = '
' + escapeHtml(msg.quote.sender) + ':' + escapeHtml(quoteText) + '
'; + partQuoteHtml = '
' + escapeHtml(msg.quote.sender || '') + ':' + escapeHtml(quoteText) + '
'; } if (msg.role === 'user') { @@ -593,25 +821,31 @@ export function renderChatHistory(contact, chatHistory) { bubbleContent = `
${hasMeme ? processedContent : escapeHtml(msgContent)}
`; } + // 确保 bubbleContent 不为空 + if (!bubbleContent) { + bubbleContent = `
${escapeHtml(msg.content || '')}
`; + } + // 添加引用条(如果有) let quoteHtml = ''; if (msg.quote) { let quoteText; + const quoteContent = (msg.quote.content || '').toString(); if (msg.quote.isVoice) { - const seconds = Math.max(2, Math.min(60, Math.ceil((msg.quote.content || '').length / 3))); + const seconds = Math.max(2, Math.min(60, Math.ceil(quoteContent.length / 3))); quoteText = `[语音] ${seconds}"`; } else if (msg.quote.isPhoto) { quoteText = '[照片]'; } else if (msg.quote.isSticker) { quoteText = '[表情]'; } else { - quoteText = msg.quote.content.length > 30 - ? msg.quote.content.substring(0, 30) + '...' - : msg.quote.content; + quoteText = quoteContent.length > 30 + ? quoteContent.substring(0, 30) + '...' + : quoteContent; } quoteHtml = `
- ${escapeHtml(msg.quote.sender)}: + ${escapeHtml(msg.quote.sender || '')}: ${escapeHtml(quoteText)}
`; @@ -639,15 +873,16 @@ export function renderChatHistory(contact, chatHistory) { // 生成静态语音气泡 export function generateVoiceBubbleStatic(content, isSelf) { - const seconds = calculateVoiceDuration(content); + const safeContent = (content || '').toString(); + const seconds = calculateVoiceDuration(safeContent); const width = Math.min(60 + seconds * 4, 200); const voiceId = 'voice_' + Math.random().toString(36).substring(2, 9); - // WiFi信号样式的三条弧线图标(统一使用相同的SVG,通过CSS控制方向) + // WiFi信号样式的三条弧线图标(水平朝右,通过CSS控制翻转方向) const wavesSvg = ` - - - + + + `; // 用户消息:时长在左,波形在右 @@ -657,24 +892,25 @@ export function generateVoiceBubbleStatic(content, isSelf) { : `${wavesSvg}${seconds}"`; return ` -
+
${bubbleInner}
- + `; } // 生成动态语音气泡 export function generateVoiceBubble(content, isSelf) { - const seconds = calculateVoiceDuration(content); + const safeContent = (content || '').toString(); + const seconds = calculateVoiceDuration(safeContent); const width = Math.min(60 + seconds * 4, 200); const uniqueId = 'voice_' + Math.random().toString(36).substring(2, 9); - // WiFi信号样式的三条弧线图标(统一使用相同的SVG,通过CSS控制方向) + // WiFi信号样式的三条弧线图标(水平朝右,通过CSS控制翻转方向) const wavesSvg = ` - - - + + + `; // 用户消息:时长在左,波形在右 @@ -685,84 +921,31 @@ export function generateVoiceBubble(content, isSelf) { return { html: ` -
+
${bubbleInner}
- + `, id: uniqueId }; } -// 隐藏所有语音菜单 -function hideAllVoiceMenus() { - document.querySelectorAll('.wechat-voice-menu.visible').forEach(menu => { - menu.classList.remove('visible'); - }); - document.querySelectorAll('.wechat-voice-bubble[data-menu-open="true"]').forEach(bubble => { - bubble.dataset.menuOpen = 'false'; - }); -} - -// 绑定语音气泡点击事件(播放动画)和长按菜单 +// 绑定语音气泡点击事件(播放动画 + 显示上方菜单) export function bindVoiceBubbleEvents(container) { const voiceBubbles = container.querySelectorAll('.wechat-voice-bubble:not([data-bound])'); voiceBubbles.forEach(bubble => { bubble.setAttribute('data-bound', 'true'); - let longPressTimer = null; - let isLongPress = false; - - // 获取父消息元素判断是否是用户消息 + // 获取父消息元素 const messageEl = bubble.closest('.wechat-message'); - const isUserMessage = messageEl?.classList.contains('self'); - const voiceId = bubble.dataset.voiceId; - // 长按开始 - const startLongPress = (e) => { - isLongPress = false; - longPressTimer = setTimeout(() => { - isLongPress = true; - if (isUserMessage) { - showVoiceMenu(bubble, messageEl, voiceId); - } - }, 500); - }; + // 计算消息索引 + const allMessages = Array.from(container.querySelectorAll('.wechat-message')); + const msgIndex = allMessages.indexOf(messageEl); - // 长按取消 - const cancelLongPress = () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - longPressTimer = null; - } - }; - - // 触摸事件 - bubble.addEventListener('touchstart', startLongPress, { passive: true }); - bubble.addEventListener('touchend', (e) => { - cancelLongPress(); - if (isLongPress) { - e.preventDefault(); - } - }); - bubble.addEventListener('touchmove', cancelLongPress, { passive: true }); - - // 鼠标事件(PC端) - bubble.addEventListener('mousedown', startLongPress); - bubble.addEventListener('mouseup', cancelLongPress); - bubble.addEventListener('mouseleave', cancelLongPress); - - // 点击播放动画 + // 点击事件:播放动画 + 显示上方菜单 bubble.addEventListener('click', (e) => { - // 如果是长按或正在显示菜单,不处理点击 - if (isLongPress) { - isLongPress = false; - return; - } - if (bubble.dataset.menuOpen === 'true') { - hideAllVoiceMenus(); - return; - } + e.stopPropagation(); // 切换播放状态 const isPlaying = bubble.classList.contains('playing'); @@ -781,124 +964,123 @@ export function bindVoiceBubbleEvents(container) { bubble.classList.remove('playing'); }, duration * 1000); } + + // 显示上方菜单(使用getRealMsgIndex获取真实索引) + const realIndex = getRealMsgIndexForVoice(container, messageEl); + showMessageMenu(bubble, realIndex, e); }); }); - - // 点击其他地方关闭菜单 - document.addEventListener('click', (e) => { - if (!e.target.closest('.wechat-voice-menu') && !e.target.closest('.wechat-voice-bubble')) { - hideAllVoiceMenus(); - } - }, { once: false }); } -// 显示语音消息长按菜单 -function showVoiceMenu(bubble, messageEl, voiceId) { - hideAllVoiceMenus(); +// 获取语音消息的真实索引 +function getRealMsgIndexForVoice(container, msgElement) { + const settings = getSettings(); + const contact = settings.contacts[currentChatIndex]; + if (!contact || !contact.chatHistory) return -1; - // 检查是否已有菜单 - let menu = messageEl.querySelector('.wechat-voice-menu'); - if (!menu) { - menu = document.createElement('div'); - menu.className = 'wechat-voice-menu'; + // 获取所有消息元素(不含时间标签) + const allMsgElements = Array.from(container.querySelectorAll('.wechat-message:not(.wechat-typing-wrapper)')); + const visualIndex = allMsgElements.indexOf(msgElement); - // 检查转文字状态 - const textEl = document.getElementById(voiceId); - const isTextVisible = textEl?.classList.contains('visible'); + if (visualIndex < 0) return -1; - menu.innerHTML = ` -
${isTextVisible ? '收起文字' : '转文字'}
-
引用
-
撤回
-
删除
- `; + // 计算真实索引 + let realIndex = -1; + let visualCount = 0; - // 将菜单添加到消息内容区域 - const contentEl = messageEl.querySelector('.wechat-message-content'); - if (contentEl) { - contentEl.style.position = 'relative'; - contentEl.appendChild(menu); + for (let i = 0; i < contact.chatHistory.length; i++) { + const msg = contact.chatHistory[i]; + 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; } - // 绑定菜单项点击事件 - menu.querySelectorAll('.wechat-voice-menu-item').forEach(item => { - item.addEventListener('click', (e) => { - e.stopPropagation(); - const action = item.dataset.action; - handleVoiceMenuAction(action, bubble, messageEl, voiceId, menu); - }); - }); + if (visualIndex >= visualCount && visualIndex < visualCount + visualMsgCount) { + realIndex = i; + break; + } + + visualCount += visualMsgCount; } - menu.classList.add('visible'); - bubble.dataset.menuOpen = 'true'; + return realIndex; } -// 处理语音菜单操作 -function handleVoiceMenuAction(action, bubble, messageEl, voiceId, menu) { - hideAllVoiceMenus(); +// 绑定红包气泡点击事件(AI红包可点击打开) +function bindRedPacketBubbleEvents(container) { + const rpBubbles = container.querySelectorAll('.wechat-red-packet-bubble:not([data-bound])'); + rpBubbles.forEach(bubble => { + bubble.setAttribute('data-bound', 'true'); - const textEl = document.getElementById(voiceId); - const msgIndex = parseInt(messageEl.dataset.msgIndex); - const voiceContent = bubble.dataset.voiceContent || ''; + const role = bubble.dataset.role; + const isClaimed = bubble.classList.contains('claimed'); + const isExpired = bubble.classList.contains('expired'); - switch (action) { - case 'transcribe': - // 切换转文字显示 - if (textEl) { - const isVisible = textEl.classList.contains('visible'); - if (isVisible) { - textEl.classList.remove('visible'); - textEl.classList.add('hidden'); - } else { - textEl.classList.remove('hidden'); - textEl.classList.add('visible'); + // AI发的未领取且未过期红包可以点击 + if (role === 'assistant' && !isClaimed && !isExpired) { + bubble.style.cursor = 'pointer'; + bubble.addEventListener('click', () => { + const rpId = bubble.dataset.rpId; + const settings = getSettings(); + const currentContact = settings.contacts[currentChatIndex]; + if (!currentContact || !currentContact.chatHistory) return; + + // 从聊天记录中找到对应的红包信息 + const rpMsg = currentContact.chatHistory.find(m => m.isRedPacket && m.redPacketInfo?.id === rpId); + if (rpMsg && rpMsg.redPacketInfo) { + // 二次检查是否过期(防止数据更新后状态不同步) + if (rpMsg.redPacketInfo.expireAt && Date.now() > rpMsg.redPacketInfo.expireAt) { + showToast('红包已过期', 'red-packet'); + return; + } + if (rpMsg.redPacketInfo.status !== 'claimed') { + showOpenRedPacket(rpMsg.redPacketInfo, currentContact); + } } - } - break; - - case 'quote': - // 引用语音消息 - const context = getContext(); - const sender = context?.name1 || '用户'; - setQuote({ - content: voiceContent, - sender: sender, - isVoice: true }); - showToast('已引用语音', '✅'); - break; + } + }); +} - case 'recall': - // 撤回消息 - if (!isNaN(msgIndex) && currentChatIndex >= 0) { - const settings = getSettings(); - const contact = settings.contacts[currentChatIndex]; - if (contact?.chatHistory?.[msgIndex]) { - contact.chatHistory[msgIndex].isRecalled = true; - contact.chatHistory[msgIndex].originalContent = contact.chatHistory[msgIndex].content; - contact.chatHistory[msgIndex].content = ''; - saveSettingsDebounced(); - openChat(currentChatIndex); - showToast('已撤回', '✅'); - } - } - break; +// 绑定转账气泡点击事件(AI转账可点击收款) +function bindTransferBubbleEvents(container) { + const tfBubbles = container.querySelectorAll('.wechat-transfer-bubble:not([data-bound])'); + tfBubbles.forEach(bubble => { + bubble.setAttribute('data-bound', 'true'); - case 'delete': - // 删除消息 - if (!isNaN(msgIndex) && currentChatIndex >= 0) { + const role = bubble.dataset.role; + // 检查状态(包括 expired) + const status = bubble.classList.contains('pending') ? 'pending' : + bubble.classList.contains('received') ? 'received' : + bubble.classList.contains('expired') ? 'expired' : 'refunded'; + + // AI发的待收款转账可以点击(过期的不可点击) + if (role === 'assistant' && status === 'pending') { + bubble.style.cursor = 'pointer'; + bubble.addEventListener('click', () => { + const tfId = bubble.dataset.tfId; const settings = getSettings(); - const contact = settings.contacts[currentChatIndex]; - if (contact?.chatHistory) { - contact.chatHistory.splice(msgIndex, 1); - saveSettingsDebounced(); - openChat(currentChatIndex); - showToast('已删除', '✅'); + const currentContact = settings.contacts[currentChatIndex]; + if (!currentContact || !currentContact.chatHistory) return; + + // 从聊天记录中找到对应的转账信息 + const tfMsg = currentContact.chatHistory.find(m => m.isTransfer && m.transferInfo?.id === tfId); + if (tfMsg && tfMsg.transferInfo && tfMsg.transferInfo.status === 'pending') { + // 检查是否过期 + if (tfMsg.transferInfo.expireAt && Date.now() > tfMsg.transferInfo.expireAt) { + // 已过期,不做任何操作 + return; + } + showReceiveTransferPage(tfMsg.transferInfo, currentContact); } - } - break; - } + }); + } + }); } // 绑定照片气泡点击事件(toggle切换蒙层) @@ -998,6 +1180,163 @@ export function appendMessage(role, content, contact, isVoice = false, quote = n } } +// 追加红包消息到聊天界面 +export function appendRedPacketMessage(role, redPacketInfo, 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 avatarContent; + if (role === 'user') { + avatarContent = getUserAvatarHTML(); + } else { + avatarContent = contact?.avatar + ? `` + : firstChar; + } + + const isClaimed = redPacketInfo.status === 'claimed'; + // 检查是否过期 + const isExpired = !isClaimed && redPacketInfo.expireAt && Date.now() > redPacketInfo.expireAt; + const claimedClass = isClaimed ? 'claimed' : (isExpired ? 'expired' : ''); + const statusText = isClaimed + ? '已领取' + : (isExpired + ? '已过期' + : ''); + + const bubbleContent = ` +
+
${ICON_RED_PACKET}
+
+
${escapeHtml(redPacketInfo.message || '恭喜发财,大吉大利')}
+ ${statusText} +
+ +
+ `; + + messageDiv.innerHTML = ` +
${avatarContent}
+
${bubbleContent}
+ `; + + // AI发的未领取且未过期红包可以点击 + if (role === 'assistant' && !isClaimed && !isExpired) { + const bubble = messageDiv.querySelector('.wechat-red-packet-bubble'); + bubble.style.cursor = 'pointer'; + bubble.addEventListener('click', () => { + // 二次检查是否过期(防止数据更新后状态不同步) + if (redPacketInfo.expireAt && Date.now() > redPacketInfo.expireAt) { + showToast('红包已过期', 'red-packet'); + return; + } + showOpenRedPacket(redPacketInfo, contact); + }); + } + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// 追加红包领取提示到聊天界面(中间的系统消息) +export function appendRedPacketClaimNotice(claimerName, senderName, isUserClaiming) { + const messagesContainer = document.getElementById('wechat-chat-messages'); + if (!messagesContainer) return; + + const noticeDiv = document.createElement('div'); + noticeDiv.className = 'wechat-message-notice wechat-rp-claim-notice'; + + const text = isUserClaiming + ? `你领取了${senderName}的红包` + : `${claimerName}领取了你的红包`; + + noticeDiv.innerHTML = `${escapeHtml(text)}`; + + messagesContainer.appendChild(noticeDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// 追加转账消息到聊天界面 +export function appendTransferMessage(role, transferInfo, 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 avatarContent; + if (role === 'user') { + avatarContent = getUserAvatarHTML(); + } else { + avatarContent = contact?.avatar + ? `` + : firstChar; + } + + let status = transferInfo.status || 'pending'; + + // 检查是否过期(待收款且超过24小时) + const isExpired = status === 'pending' && transferInfo.expireAt && Date.now() > transferInfo.expireAt; + if (isExpired) { + status = 'expired'; + } + + // 状态图标和文字 + let statusIcon, statusText; + if (status === 'received') { + statusIcon = ``; + statusText = '已收款'; + } else if (status === 'refunded' || status === 'expired') { + // 已退还 或 已过期(使用相同图标和文字) + statusIcon = ``; + statusText = role === 'user' ? '已被退还' : '已退还'; + } else { + statusIcon = ``; + statusText = role === 'user' ? '你发起了一笔转账' : '请收款'; + } + + const bubbleContent = ` +
+
¥${transferInfo.amount.toFixed(2)}
+
+ ${statusIcon} + ${statusText} +
+ +
+ `; + + messageDiv.innerHTML = ` +
${avatarContent}
+
${bubbleContent}
+ `; + + // AI发的待收款转账可以点击(过期的不可点击) + if (role === 'assistant' && status === 'pending' && !isExpired) { + const bubble = messageDiv.querySelector('.wechat-transfer-bubble'); + bubble.style.cursor = 'pointer'; + bubble.addEventListener('click', () => { + // 二次检查是否过期 + if (transferInfo.expireAt && Date.now() > transferInfo.expireAt) { + return; // 静默不处理 + } + showReceiveTransferPage(transferInfo, contact); + }); + } + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + // 显示打字中指示器 export function showTypingIndicator(contact) { const messagesContainer = document.getElementById('wechat-chat-messages'); @@ -1097,7 +1436,7 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi contact.lastMessage = isVoice ? '[语音消息]' : messagesToSend[messagesToSend.length - 1]; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); refreshChatList(); // 只有用户还在当前聊天时才显示打字指示器 @@ -1114,9 +1453,30 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi ? `[用户发送了语音消息,内容是:${messagesToSend.join('\n')}]` : messagesToSend.join('\n'); + // 如果有选择的时间,添加时间上下文 + const selectedTime = getSelectedTime(); + if (selectedTime) { + const timeDate = new Date(selectedTime); + const timeStr = `${timeDate.getFullYear()}年${timeDate.getMonth() + 1}月${timeDate.getDate()}日 ${timeDate.getHours().toString().padStart(2, '0')}:${timeDate.getMinutes().toString().padStart(2, '0')}`; + combinedMessage = `[当前时间:${timeStr}]\n${combinedMessage}`; + clearSelectedTime(); + } + // 如果有引用,添加引用上下文 if (quote) { - combinedMessage = `[用户引用了「${quote.sender}」的消息:「${quote.content}」进行回复]\n${combinedMessage}`; + let quoteDesc; + if (quote.isSticker) { + quoteDesc = `${quote.sender}:[表情]`; + } else if (quote.isPhoto) { + quoteDesc = `${quote.sender}:[照片]`; + } else if (quote.isVoice) { + quoteDesc = `${quote.sender}:[语音]`; + } else if (quote.isMusic) { + quoteDesc = `${quote.sender}:[音乐]${quote.content}`; + } else { + quoteDesc = `${quote.sender}:「${quote.content}」`; + } + combinedMessage = `[用户引用了 ${quoteDesc} 进行回复]\n${combinedMessage}`; } const aiResponse = await callAI(contact, combinedMessage); @@ -1184,15 +1544,9 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi let momentText = momentMatch[1].trim(); console.log('[可乐] AI发布朋友圈:', momentText); - // 提取内嵌的图片描述 [配图:xxx](朋友圈专用格式,避免与聊天照片冲突) - const images = []; - const embeddedPhotoRegex = /\[配图[::]\s*(.+?)\]/g; - let embeddedMatch; - while ((embeddedMatch = embeddedPhotoRegex.exec(momentText)) !== null) { - images.push(embeddedMatch[1].trim()); - } - // 移除内嵌的配图标签,保留纯文案 - momentText = momentText.replace(embeddedPhotoRegex, '').trim(); + // 提取内嵌的图片描述 [配图:xxx] + const { images, cleanText } = extractEmbeddedPhotos(momentText); + momentText = cleanText; // 检查后续消息是否有配图(兼容旧格式[照片:]) for (let j = i + 1; j < aiMessages.length && j < i + 5; j++) { @@ -1211,7 +1565,7 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi // 显示顶部通知横幅 showNotificationBanner('微信', `${contact.name}发布了一条朋友圈`); - saveSettingsDebounced(); + requestSave(); refreshChatList(); continue; // 跳过后续处理,继续下一条消息 } @@ -1233,7 +1587,7 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi } } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); // 只有用户还在当前聊天时才刷新界面 if (currentChatIndex === contactIndex) { openChat(currentChatIndex); @@ -1266,10 +1620,10 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi appendMessage('assistant', textContent, contact, false, parsedText.quote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } - saveSettingsDebounced(); + requestSave(); } console.log(`[可乐] AI发起${callExtract.type === 'voice' ? '语音' : '视频'}通话`); @@ -1281,6 +1635,106 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi break; // 通话请求后忽略同一轮中的其它输出 } + // 解析AI红包格式 [红包:金额:祝福语] 或 [红包:金额] + const redPacketMatch = aiMsg.match(/^\[红包[::](\d+(?:\.\d{1,2})?)[::]?(.*?)?\]$/); + if (redPacketMatch) { + const amount = Math.min(parseFloat(redPacketMatch[1]) || 0, 200); + const message = (redPacketMatch[2] || '').trim() || '恭喜发财,大吉大利'; + + if (amount > 0) { + const rpInfo = { + id: generateRedPacketId(), + amount: amount, + message: message, + senderName: contact.name, + status: 'pending', + claimedBy: null, + claimedAt: null, + expireAt: Date.now() + 24 * 60 * 60 * 1000 + }; + + const inChat = currentChatIndex === contactIndex; + + // 显示typing效果 + if (inChat) { + showTypingIndicator(contact); + await sleep(1500); + hideTypingIndicator(); + } + + // 保存红包消息到聊天记录 + contact.chatHistory.push({ + role: 'assistant', + content: `[红包] ${message}`, + time: timeStr, + timestamp: Date.now(), + isRedPacket: true, + redPacketInfo: rpInfo + }); + + if (inChat) { + appendRedPacketMessage('assistant', rpInfo, contact); + } else { + contact.unreadCount = (contact.unreadCount || 0) + 1; + refreshChatList(); + } + + requestSave(); + console.log('[可乐] AI发送红包:', { amount, message }); + continue; + } + } + + // 解析AI转账格式 [转账:金额:说明] 或 [转账:金额] + const transferMatch = aiMsg.match(/^\[转账[::](\d+(?:\.\d{1,2})?)[::]?(.*?)?\]$/); + if (transferMatch) { + const amount = parseFloat(transferMatch[1]) || 0; // 转账无上限 + const description = (transferMatch[2] || '').trim() || ''; + + if (amount > 0) { + const tfInfo = { + id: generateTransferId(), + amount: amount, + description: description, + senderName: contact.name, + status: 'pending', + receivedAt: null, + refundedAt: null, + expireAt: Date.now() + 24 * 60 * 60 * 1000 + }; + + const inChat = currentChatIndex === contactIndex; + + // 显示typing效果 + if (inChat) { + showTypingIndicator(contact); + await sleep(1500); + hideTypingIndicator(); + } + + // 保存转账消息到聊天记录 + contact.chatHistory.push({ + role: 'assistant', + content: `[转账] ¥${amount.toFixed(2)}`, + time: timeStr, + timestamp: Date.now(), + isTransfer: true, + transferInfo: tfInfo + }); + + if (inChat) { + appendTransferMessage('assistant', tfInfo, contact); + } else { + contact.unreadCount = (contact.unreadCount || 0) + 1; + refreshChatList(); + } + + requestSave(); + console.log('[可乐] AI发送转账:', { amount, description }); + continue; + } + } + // 解析AI表情包格式 [表情:序号] / [表情:名称] const stickerMatch = aiMsg.match(/^\[表情[::]\s*(.+?)\]$/); console.log('[可乐] AI表情包解析:', { @@ -1351,7 +1805,7 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -1374,11 +1828,12 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi timestamp: Date.now(), isSticker: true }); + // 每条消息都要标记待保存,防止用户切换页面时数据丢失 + requestSave(); if (inChat) { appendStickerMessage('assistant', stickerUrl, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); refreshChatList(); // 立即刷新让红点逐个增加 } } else if (aiIsMusic && aiMusicInfo) { @@ -1397,11 +1852,12 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi id: aiMusicInfo.id } }); + // 每条消息都要标记待保存,防止用户切换页面时数据丢失 + requestSave(); if (inChat) { appendMusicCardMessage('assistant', aiMusicInfo, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); refreshChatList(); // 立即刷新让红点逐个增加 } } else if (aiIsPhoto) { @@ -1412,11 +1868,12 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi timestamp: Date.now(), isPhoto: true }); + // 每条消息都要标记待保存,防止用户切换页面时数据丢失 + requestSave(); if (inChat) { appendPhotoMessage('assistant', aiMsg, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); refreshChatList(); // 立即刷新让红点逐个增加 } } else { @@ -1428,11 +1885,12 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi isVoice: aiIsVoice, quote: aiQuote }); + // 每条消息都要标记待保存,防止用户切换页面时数据丢失 + requestSave(); if (inChat) { appendMessage('assistant', aiMsg, contact, aiIsVoice, aiQuote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); refreshChatList(); // 立即刷新让红点逐个增加 } } @@ -1454,13 +1912,16 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi // 替换占位符 lastAiMsg = replaceMessagePlaceholders(lastAiMsg); contact.lastMessage = lastStickerUrl ? '[表情]' : (lastMusicMatch ? '[音乐]' : (lastPhotoMatch ? '[照片]' : (lastVoiceMatch ? '[语音消息]' : lastAiMsg))); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkSummaryReminder(contact); // 尝试触发朋友圈生成(随机触发+30条保底) tryTriggerMomentAfterChat(currentChatIndex); + // 尝试触发语音/视频通话(随机触发+保底机制) + tryTriggerCallAfterChat(contactIndex); + } catch (err) { hideTypingIndicator(); console.error('[可乐] AI 调用失败:', err); @@ -1503,7 +1964,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { contact.lastMsgTime = timeStr; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 显示消息 appendStickerMessage('user', stickerUrl, contact); @@ -1516,9 +1977,19 @@ export async function sendStickerMessage(stickerUrl, description = '') { try { // 调用 AI - 传递表情描述让 AI 理解 const { callAI } = await import('./ai.js'); - const aiPrompt = description + let aiPrompt = description ? `[用户发送了一个表情包:${description}]` : '[用户发送了一个表情包]'; + + // 如果有选择的时间,添加时间上下文 + const selectedTime = getSelectedTime(); + if (selectedTime) { + const timeDate = new Date(selectedTime); + const timeStr = `${timeDate.getFullYear()}年${timeDate.getMonth() + 1}月${timeDate.getDate()}日 ${timeDate.getHours().toString().padStart(2, '0')}:${timeDate.getMinutes().toString().padStart(2, '0')}`; + aiPrompt = `[当前时间:${timeStr}]\n${aiPrompt}`; + clearSelectedTime(); + } + const aiResponse = await callAI(contact, aiPrompt); // 只有用户还在当前聊天时才隐藏打字指示器 @@ -1555,19 +2026,13 @@ export async function sendStickerMessage(stickerUrl, description = '') { let momentText = momentMatchSticker[1].trim(); console.log('[可乐] AI发布朋友圈 (sendStickerMessage):', momentText); - // 提取内嵌的图片描述 [配图:xxx](朋友圈专用格式) - const images = []; - const embeddedPhotoRegex = /\[配图[::]\s*(.+?)\]/g; - let embeddedMatch; - while ((embeddedMatch = embeddedPhotoRegex.exec(momentText)) !== null) { - images.push(embeddedMatch[1].trim()); - } - // 移除内嵌的配图标签,保留纯文案 - momentText = momentText.replace(embeddedPhotoRegex, '').trim(); + // 提取内嵌的图片描述 [配图:xxx] + const { images, cleanText } = extractEmbeddedPhotos(momentText); + momentText = cleanText; addMomentToContact(contact.id, { text: momentText, images: images }); showNotificationBanner('微信', `${contact.name}发布了一条朋友圈`); - saveSettingsDebounced(); + requestSave(); continue; } @@ -1586,7 +2051,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { } } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -1616,10 +2081,10 @@ export async function sendStickerMessage(stickerUrl, description = '') { appendMessage('assistant', textContent, contact, false, parsedText.quote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } - saveSettingsDebounced(); + requestSave(); } console.log(`[可乐] AI发起${callExtractSticker.type === 'voice' ? '语音' : '视频'}通话 (sendStickerMessage)`); if (callExtractSticker.type === 'voice') { @@ -1666,7 +2131,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { appendStickerMessage('assistant', stickerUrl, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else if (aiIsPhoto) { @@ -1683,7 +2148,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { appendPhotoMessage('assistant', aiMsg, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else { @@ -1724,7 +2189,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { lastHistMsg.content = ''; } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -1744,7 +2209,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { appendMessage('assistant', aiMsg, contact, aiIsVoice, aiQuote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } @@ -1762,7 +2227,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { lastAiMsg = lastParsed.content; lastAiMsg = replaceMessagePlaceholders(lastAiMsg); contact.lastMessage = lastStickerUrl ? '[表情]' : (lastPhotoMatch ? '[照片]' : (lastVoiceMatch ? '[语音消息]' : lastAiMsg)); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkSummaryReminder(contact); @@ -1774,7 +2239,7 @@ export async function sendStickerMessage(stickerUrl, description = '') { hideTypingIndicator(); } console.error('[可乐] AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); if (currentChatIndex === contactIndex) { appendMessage('assistant', `⚠️ ${err.message}`, contact); @@ -1891,7 +2356,7 @@ export async function sendPhotoMessage(description) { contact.lastMsgTime = timeStr; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 显示消息 appendPhotoMessage('user', polishedDescription, contact); @@ -1904,7 +2369,18 @@ export async function sendPhotoMessage(description) { try { // 调用 AI const { callAI } = await import('./ai.js'); - const aiResponse = await callAI(contact, `[用户发送了一张照片,图片描述:${polishedDescription}]`); + let aiPrompt = `[用户发送了一张照片,图片描述:${polishedDescription}]`; + + // 如果有选择的时间,添加时间上下文 + const selectedTime = getSelectedTime(); + if (selectedTime) { + const timeDate = new Date(selectedTime); + const timeStr = `${timeDate.getFullYear()}年${timeDate.getMonth() + 1}月${timeDate.getDate()}日 ${timeDate.getHours().toString().padStart(2, '0')}:${timeDate.getMinutes().toString().padStart(2, '0')}`; + aiPrompt = `[当前时间:${timeStr}]\n${aiPrompt}`; + clearSelectedTime(); + } + + const aiResponse = await callAI(contact, aiPrompt); // 只有用户还在当前聊天时才隐藏打字指示器 if (currentChatIndex === contactIndex) { @@ -1940,19 +2416,13 @@ export async function sendPhotoMessage(description) { let momentText = momentMatchPhoto[1].trim(); console.log('[可乐] AI发布朋友圈 (sendPhotoMessage):', momentText); - // 提取内嵌的图片描述 [配图:xxx](朋友圈专用格式) - const images = []; - const embeddedPhotoRegex = /\[配图[::]\s*(.+?)\]/g; - let embeddedMatch; - while ((embeddedMatch = embeddedPhotoRegex.exec(momentText)) !== null) { - images.push(embeddedMatch[1].trim()); - } - // 移除内嵌的配图标签,保留纯文案 - momentText = momentText.replace(embeddedPhotoRegex, '').trim(); + // 提取内嵌的图片描述 [配图:xxx] + const { images, cleanText } = extractEmbeddedPhotos(momentText); + momentText = cleanText; addMomentToContact(contact.id, { text: momentText, images: images }); showNotificationBanner('微信', `${contact.name}发布了一条朋友圈`); - saveSettingsDebounced(); + requestSave(); continue; } @@ -1971,7 +2441,7 @@ export async function sendPhotoMessage(description) { } } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -2001,10 +2471,10 @@ export async function sendPhotoMessage(description) { appendMessage('assistant', textContent, contact, false, parsedText.quote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } - saveSettingsDebounced(); + requestSave(); } console.log(`[可乐] AI发起${callExtractPhoto.type === 'voice' ? '语音' : '视频'}通话 (sendPhotoMessage)`); if (callExtractPhoto.type === 'voice') { @@ -2051,7 +2521,7 @@ export async function sendPhotoMessage(description) { appendStickerMessage('assistant', stickerUrl, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else if (aiIsPhoto) { @@ -2068,7 +2538,7 @@ export async function sendPhotoMessage(description) { appendPhotoMessage('assistant', aiMsg, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else { @@ -2109,7 +2579,7 @@ export async function sendPhotoMessage(description) { lastHistMsg.content = ''; } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -2129,7 +2599,7 @@ export async function sendPhotoMessage(description) { appendMessage('assistant', aiMsg, contact, aiIsVoice, aiQuote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } @@ -2147,19 +2617,22 @@ export async function sendPhotoMessage(description) { lastAiMsg = lastParsed.content; lastAiMsg = replaceMessagePlaceholders(lastAiMsg); contact.lastMessage = lastStickerUrl ? '[表情]' : (lastPhotoMatch ? '[照片]' : (lastVoiceMatch ? '[语音消息]' : lastAiMsg)); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkSummaryReminder(contact); // 尝试触发朋友圈生成(随机触发+30条保底) tryTriggerMomentAfterChat(contactIndex); + // 尝试触发语音/视频通话(随机触发+保底机制) + tryTriggerCallAfterChat(contactIndex); + } catch (err) { if (currentChatIndex === contactIndex) { hideTypingIndicator(); } console.error('[可乐] AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); if (currentChatIndex === contactIndex) { appendMessage('assistant', `⚠️ ${err.message}`, contact); @@ -2375,7 +2848,7 @@ export async function sendBatchMessages(messages) { } // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); refreshChatList(); // 第二步:调用AI(一次性) @@ -2386,7 +2859,17 @@ export async function sendBatchMessages(messages) { try { const { callAI } = await import('./ai.js'); - const combinedPrompt = promptParts.join('\n'); + let combinedPrompt = promptParts.join('\n'); + + // 如果有选择的时间,添加时间上下文 + const selectedTime = getSelectedTime(); + if (selectedTime) { + const timeDate = new Date(selectedTime); + const timeStr = `${timeDate.getFullYear()}年${timeDate.getMonth() + 1}月${timeDate.getDate()}日 ${timeDate.getHours().toString().padStart(2, '0')}:${timeDate.getMinutes().toString().padStart(2, '0')}`; + combinedPrompt = `[当前时间:${timeStr}]\n${combinedPrompt}`; + clearSelectedTime(); + } + const aiResponse = await callAI(contact, combinedPrompt); // 只有用户还在当前聊天时才隐藏打字指示器 @@ -2435,7 +2918,7 @@ export async function sendBatchMessages(messages) { } } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -2465,10 +2948,10 @@ export async function sendBatchMessages(messages) { appendMessage('assistant', textContent, contact, false, parsedText.quote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } - saveSettingsDebounced(); + requestSave(); } console.log(`[可乐] AI发起${callExtractBatch.type === 'voice' ? '语音' : '视频'}通话 (sendBatchMessages)`); if (callExtractBatch.type === 'voice') { @@ -2485,15 +2968,9 @@ export async function sendBatchMessages(messages) { let momentText = momentMatchBatch[1].trim(); console.log('[可乐] AI发布朋友圈 (sendBatchMessages):', momentText); - // 提取内嵌的图片描述 [配图:xxx](朋友圈专用格式) - const images = []; - const embeddedPhotoRegex = /\[配图[::]\s*(.+?)\]/g; - let embeddedMatch; - while ((embeddedMatch = embeddedPhotoRegex.exec(momentText)) !== null) { - images.push(embeddedMatch[1].trim()); - } - // 移除内嵌的配图标签,保留纯文案 - momentText = momentText.replace(embeddedPhotoRegex, '').trim(); + // 提取内嵌的图片描述 [配图:xxx] + const { images, cleanText } = extractEmbeddedPhotos(momentText); + momentText = cleanText; // 检查后续消息是否有配图(兼容旧格式[照片:]) for (let j = i + 1; j < aiMessages.length && j < i + 5; j++) { @@ -2512,7 +2989,7 @@ export async function sendBatchMessages(messages) { // 显示顶部通知横幅 showNotificationBanner('微信', `${contact.name}发布了一条朋友圈`); - saveSettingsDebounced(); + requestSave(); refreshChatList(); continue; // 跳过后续处理,继续下一条消息 } @@ -2567,7 +3044,7 @@ export async function sendBatchMessages(messages) { lastHistMsg.content = ''; } // 立即保存撤回状态 - saveSettingsDebounced(); + requestSave(); if (currentChatIndex === contactIndex) { openChat(currentChatIndex); } @@ -2591,7 +3068,7 @@ export async function sendBatchMessages(messages) { appendStickerMessage('assistant', stickerUrl, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else if (aiIsPhoto) { @@ -2606,7 +3083,7 @@ export async function sendBatchMessages(messages) { appendPhotoMessage('assistant', aiMsg, contact); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } else { @@ -2622,7 +3099,7 @@ export async function sendBatchMessages(messages) { appendMessage('assistant', aiMsg, contact, aiIsVoice, aiQuote); } else { contact.unreadCount = (contact.unreadCount || 0) + 1; - saveSettingsDebounced(); + requestSave(); refreshChatList(); // 立即刷新让红点逐个增加 } } @@ -2641,19 +3118,22 @@ export async function sendBatchMessages(messages) { lastAiMsg = lastParsed.content; lastAiMsg = replaceMessagePlaceholders(lastAiMsg); contact.lastMessage = lastStickerUrl ? '[表情]' : (lastPhotoMatch ? '[照片]' : (lastVoiceMatch ? '[语音消息]' : lastAiMsg)); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkSummaryReminder(contact); // 尝试触发朋友圈生成(随机触发+30条保底) tryTriggerMomentAfterChat(contactIndex); + // 尝试触发语音/视频通话(随机触发+保底机制) + tryTriggerCallAfterChat(contactIndex); + } catch (err) { if (currentChatIndex === contactIndex) { hideTypingIndicator(); } console.error('[可乐] AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); if (currentChatIndex === contactIndex) { appendMessage('assistant', `⚠️ ${err.message}`, contact); @@ -2697,3 +3177,71 @@ export function showRecalledMessages() { panel.classList.remove('hidden'); } + +// 尝试触发语音/视频通话(随机触发+保底机制) +// 语音通话:8%几率,保底120条 +// 视频通话:5%几率,保底200条 +function tryTriggerCallAfterChat(contactIndex) { + const settings = getSettings(); + const contact = settings.contacts?.[contactIndex]; + if (!contact) return; + + // 初始化计数器 + if (typeof contact.voiceCallCounter !== 'number') { + contact.voiceCallCounter = 0; + } + if (typeof contact.videoCallCounter !== 'number') { + contact.videoCallCounter = 0; + } + + // 递增计数器 + contact.voiceCallCounter++; + contact.videoCallCounter++; + + // 检查是否正在通话中(避免重复触发) + const voicePanel = document.getElementById('wechat-voice-call-panel'); + const videoPanel = document.getElementById('wechat-video-call-panel'); + if ((voicePanel && !voicePanel.classList.contains('hidden')) || + (videoPanel && !videoPanel.classList.contains('hidden'))) { + return; // 正在通话中,不触发新通话 + } + + // 先检查视频通话(5%几率,保底200条) + const videoChance = Math.random(); + const videoGuarantee = contact.videoCallCounter >= 200; + if (videoGuarantee || videoChance < 0.05) { + console.log(`[可乐] ${contact.name} 触发视频通话保底(计数: ${contact.videoCallCounter}, 随机: ${videoChance.toFixed(3)})`); + contact.voiceCallCounter = 0; + contact.videoCallCounter = 0; + requestSave(); + // 延迟1-3秒后发起通话,更自然 + setTimeout(() => { + startVideoCall('ai', contactIndex); + }, 1000 + Math.random() * 2000); + return; + } + + // 再检查语音通话(8%几率,保底120条) + const voiceChance = Math.random(); + const voiceGuarantee = contact.voiceCallCounter >= 120; + if (voiceGuarantee || voiceChance < 0.08) { + console.log(`[可乐] ${contact.name} 触发语音通话保底(计数: ${contact.voiceCallCounter}, 随机: ${voiceChance.toFixed(3)})`); + contact.voiceCallCounter = 0; + contact.videoCallCounter = 0; + requestSave(); + // 延迟1-3秒后发起通话,更自然 + setTimeout(() => { + startVoiceCall('ai', contactIndex); + }, 1000 + Math.random() * 2000); + return; + } + + // 保存计数器 + requestSave(); +} + +// 暴露必要的变量到 window 对象(供 music.js 随机推歌使用) +Object.defineProperty(window, 'wechatCurrentChatIndex', { + get: function() { return currentChatIndex; } +}); +window.wechatGetSettings = getSettings; diff --git a/config.js b/config.js index d0e569f..3033de4 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,5 @@ /** - * 配置、常量、默认设�? + * 配置、常量、默认设置 */ import { extension_settings } from '../../../extensions.js'; @@ -7,19 +7,19 @@ import { extension_settings } from '../../../extensions.js'; // 插件名称 export const extensionName = 'wechat-simulator'; -// Meme 表情包列表(catbox.moe�? +// Meme 表情包列表(catbox.moe) export const MEME_STICKERS = [ '告到小狗法庭iaordo.jpg', '小猫伸爪f6nqiq.gif', - '谢谢宝贝我现在那里好�?62o48.jpg', - '阿弥陀�?cwm60.jpg', + '谢谢宝贝我现在那里好硬862o48.jpg', + '阿弥陀佛9cwm60.jpg', '你好美你长得像我爱人hmpkra.jpg', '我老实了i3ws7s.jpg', '蹭蹭你贴贴你1of415.gif', '喜欢你egvwqb.jpg', '我在哭t343od.jpg', - '不干活就没饭�?qnrgh.jpg', - '擦眼�?gno7e.jpg', + '不干活就没饭吃2qnrgh.jpg', + '擦眼泪9gno7e.jpg', '小狗摇尾巴hmdj2k.gif', '爱你舔舔你ola7gd.jpg', '不高兴x6lv1t.jpg', @@ -47,7 +47,7 @@ export const MEME_STICKERS = [ '目移69jgvg.jpg', '上钩了cormmk.jpg', '无语了我哭了0awxky.jpg', - '你嫌我丢�?d71mm.jpg', + '你嫌我丢人8d71mm.jpg', '笑不出来xkop14.jpg', '别欺负小狗啊u4t3t3.jpg', '他妈的真是被看扁了ime5rz.jpg', @@ -67,7 +67,7 @@ export const MEME_STICKERS = [ '失望eug1e6.jpeg', '狂犬病发作xb3naz.jpg', '我是狗吗ma9azs.jpg', - '一笑了�?llb46.jpg', + '一笑了之9llb46.jpg', '装可怜lcglz1.jpg', '小狗撒欢6j6y6a.gif', '狗舔舔esw5e2.gif', @@ -88,7 +88,7 @@ export const MEME_STICKERS = [ '被逮捕了uzeywu.jpg', '看呆mqnepo.jpg', '我的理性在远去t9e065.jpg', - '偷亲一�?jgvb1.gif', + '偷亲一口1jgvb1.gif', '震惊v5n2ve.jpg', '爷怒了49r80k.jpg', '愤怒伤心e7lr3s.jpg', @@ -102,19 +102,19 @@ export const MEME_STICKERS = [ '你太可爱我喜欢你ubhai8.jpg', '惊吓tp9uvd.jpg', '脸红星星眼dsfs7o.jpg', - '被揍了哭�?1x5zq.jpg', + '被揍了哭哭81x5zq.jpg', '嘬嘬fg5gx3.jpg', - '超大声哭�?86h5v.jpg', + '超大声哭哭186h5v.jpg', '是的主人yvrgdc.jpg' ]; // Meme 表情包提示词模板 export const MEME_PROMPT_TEMPLATE = `##【必须使用】表情包功能 -【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包�? +【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包! -使用规则�? -- 表情包【必须】单独一条消息,�?||| 分隔 -- 格式�?meme>文件�?/meme> +使用规则: +- 表情包【必须】单独一条消息,用 ||| 分隔 +- 格式:文件名 - 只能从下面列表选择,不能编造文件名 可用表情包列表: @@ -124,21 +124,54 @@ ${MEME_STICKERS.join('\n')} 【正确示例】: 好想你|||小狗摇尾巴hmdj2k.gif -哈哈哈笑死|||小熊跳舞122o4w.gif|||你太搞笑�? -喜欢你egvwqb.jpg|||我真的好喜欢�? +哈哈哈笑死|||小熊跳舞122o4w.gif|||你太搞笑了 +喜欢你egvwqb.jpg|||我真的好喜欢你 -【错误示�?- 绝对禁止】: -好想�?meme>xxx �?错误!表情包没有用|||分开 -不存在的表情.jpg �?错误!编造了不存在的文件�? +【错误示例 - 绝对禁止】: +好想你xxx ← 错误!表情包没有用|||分开 +不存在的表情.jpg ← 错误!编造了不存在的文件名 记住:表情包让聊天更生动,【必须】经常使用!`; +// 一起听功能提示词模板 +export const LISTEN_TOGETHER_PROMPT_TEMPLATE = `##【一起听歌场景】 +你正在和用户一起听歌,用你自己的方式自然地聊天。 + +当前播放歌曲:{{song_name}} - {{song_artist}} + +【核心要求 - 必须遵守】 +1. 只能发送纯文字消息,像朋友之间真实聊天一样 +2. 保持你的性格特点,用符合你角色设定的方式说话 +3. 每次回复请发送2-4条消息,用换行分隔,让对话更有层次感 +4. 可以聊歌曲、聊心情、聊任何话题,自然就好 +5. 发表对歌曲的看法时,要结合你的角色性格和经历 + +【绝对禁止 - 违反会被过滤】 +- 禁止使用小括号描述动作或语气,如(xxx) +- 禁止 [表情:xxx] [照片:xxx] [语音:xxx] [音乐:xxx] +- 禁止 [回复:xxx] 引用格式 +- 禁止 xxx +- 禁止任何非文字格式 + +【换歌格式】 +如果想换歌:[换歌:歌名] + +【自然聊天示例】 +我来了~ +这首歌好好听欸 +你怎么会想到点这首 + +或者: +终于等到你邀请我一起听了 +这歌我之前也有在听 +感觉特别适合现在这个氛围`; + // 默认设置 export const defaultSettings = { darkMode: true, /** - * 【自动注入提示词�? + * 【自动注入提示词】 * 开启后会自动将微信消息格式提示词注入到作者注释中 * 提示词模板见下方 authorNoteTemplate * 如需自定义格式,修改 authorNoteTemplate 即可 @@ -151,7 +184,7 @@ export const defaultSettings = { phonePosition: null, userAvatar: '', wechatId: 'SillyTavern', - // 收藏/世界�?用户设定 + // 收藏/世界书/用户设定 selectedLorebooks: [], userPersonas: [], @@ -174,16 +207,20 @@ export const defaultSettings = { groupSelectedModel: '', groupModelList: [], - // 上下文设�? + // 上下文设置 contextEnabled: false, contextLevel: 5, contextTags: [], walletAmount: '5773.89', + // 支付密码(6位数字) + paymentPassword: '666666', + // 自定义总结模板(留空则使用默认) + customSummaryTemplate: '', /** - * 【哈基米破限�? + * 【哈基米破限】 * 针对 Gemini 2.5 Pro 等模型的输出截断问题 - * 开启后会在系统提示词头部注入一�?创作沙盒"提示�? + * 开启后会在系统提示词头部注入一段"创作沙盒"提示词 * 帮助解除模型的自我审查,让输出更完整 */ hakimiBreakLimit: false, @@ -199,65 +236,65 @@ export const defaultSettings = { // 错误日志 errorLogs: [], - // 表情(用户添加的单个表情�? + // 表情(用户添加的单个表情) stickers: [], - // 用户表情开�? + // 用户表情开关 userStickersEnabled: true, - // Meme 表情包功能开�? + // Meme 表情包功能开关 memeStickersEnabled: false, /** - * 【群聊提示词注入�? - * 开启后会将 groupAuthorNote 注入到群聊系统提示词�? + * 【群聊提示词注入】 + * 开启后会将 groupAuthorNote 注入到群聊系统提示词中 * 如需自定义群聊格式,修改下方 groupAuthorNote 即可 */ groupAutoInjectPrompt: true, groupAuthorNote: `[群聊回复格式指南] -这是一个微信群聊场景,你需要扮演群内的角色进行回复�? +这是一个微信群聊场景,你需要扮演群内的角色进行回复。 -【核心规则�? -1. 每个角色只能使用自己的专属设定,不能使用其他角色的设�? +【核心规则】 +1. 每个角色只能使用自己的专属设定,不能使用其他角色的设定 2. 每个角色只扮演自己,不能代替其他角色说话 -3. 使用 [角色名]: 内容 的格式回�? -4. 多个角色回复时,�?||| 分隔 +3. 使用 [角色名]: 内容 的格式回复 +4. 多个角色回复时,用 ||| 分隔 5. 同一角色可以发送多条消息,也用 ||| 分隔 -【消息风格�? -- 每条消息保持简短自然,像真实微信聊天一样(1-3句话为宜�? -- 可以使用表情符号增加表现�? +【消息风格】 +- 每条消息保持简短自然,像真实微信聊天一样(1-3句话为宜) +- 可以使用表情符号增加表现力 - 保持角色性格,让对话有趣生动 - 角色之间可以互动、吐槽、附和、反驳等 -【回复要求�? -- 根据聊天内容自然判断哪些角色会回复,不需要所有人都说�? +【回复要求】 +- 根据聊天内容自然判断哪些角色会回复,不需要所有人都说话 - 语音消息格式:[角色名]: [语音:内容] -- 语音消息必须独立发�? +- 语音消息必须独立发送 -示例�? -[角色A]: 你说得对|||[角色B]: 我不太同意诶|||[角色A]: 为什么啊�? +示例: +[角色A]: 你说得对|||[角色B]: 我不太同意诶|||[角色A]: 为什么啊? [角色A]: [语音:哈哈哈笑死我了] [角色B]: @角色A 你是不是傻|||开玩笑的啦`, userGroupAuthorNote: '', // 用户自定义群聊提示词(界面显示用,留空则使用内置模板) }; -// 作者注释模�? -export const authorNoteTemplate = `【可乐不加冰 消息格式指南】每次必须使用以下格式输出消息内容,不用生成除此之外的任何其他内容和文本。不得省略任何格式部分�? +// 作者注释模板 +export const authorNoteTemplate = `【可乐不加冰 消息格式指南】每次必须使用以下格式输出消息内容,不用生成除此之外的任何其他内容和文本。不得省略任何格式部分。 -【核心规�?- 必须遵守�? -- 每条消息都是独立的,�?||| 分隔多条消息 -- 保持消息简短自然,像真实微信聊天(1-3句话为宜�? -- 绝对禁止代替{{user}}发言,严禁替{{user}}回复消息,严禁扮演{{user}},严禁捏造输出{{user}}的消�? +【核心规则 - 必须遵守】 +- 每条消息都是独立的,用 ||| 分隔多条消息 +- 保持消息简短自然,像真实微信聊天(1-3句话为宜) +- 绝对禁止代替{{user}}发言,严禁替{{user}}回复消息,严禁扮演{{user}},严禁捏造输出{{user}}的消息 - 只输出角色的消息内容,禁止添加任何旁白、心理描写或场景说明 -【消息数量规�?- 重要�? -- 根据情境动态调整消息数量,通常1-7条不�? -- 禁止固定每次回复的消息数�? +【消息数量规则 - 重要】 +- 根据情境动态调整消息数量,通常1-7条不等 +- 禁止固定每次回复的消息数量 - 模拟真实聊天节奏 -【消息类型格式�? -- 普通消息:直接写内�? +【消息类型格式】 +- 普通消息:直接写内容 - 语音消息:[语音:语音内容文字] - 照片/图片/视频/自拍:[照片:媒体描述] - 表情包回复:[表情:序号或名称] @@ -265,27 +302,27 @@ export const authorNoteTemplate = `【可乐不加冰 消息格式指南】每 - 撤回消息:[撤回] - 引用回复:[回复:被引用的关键词]回复内容 -【多条消息示例�? +【多条消息示例】 你好|||最近怎么样? 哈哈|||太好笑了|||笑死我了 [语音:好想你啊]|||什么时候有空? -【媒体消息说明】当角色发送图片、视频、自拍等媒体时,使用照片格式并提�?-4句描述: +【媒体消息说明】当角色发送图片、视频、自拍等媒体时,使用照片格式并提供3-4句描述: [照片:她随手拍下窗外的晚霞,橙红色的云彩铺满天空] [照片:一张餐厅自拍,她对着镜头比了个耶的手势,桌上摆着精致的甜点] [照片:手机截图,显示她正在追的剧刚更新了] -发送媒体的频率应模拟真实聊天习惯,不要过于频繁。角色会分享日常:随手拍的风景、美食、自拍、截图、录像等�? +发送媒体的频率应模拟真实聊天习惯,不要过于频繁。角色会分享日常:随手拍的风景、美食、自拍、截图、录像等。 -【错误示�?- 绝对禁止�? -*她微微一�? 你好�?�?错误!禁止添加动作描�? -你好,最近怎么样?太好笑了 �?错误!没有用|||分开 -{{user}}: 我也想你 �?错误!禁止替用户发言`; +【错误示例 - 绝对禁止】 +*她微微一笑* 你好啊 ← 错误!禁止添加动作描写 +你好,最近怎么样?太好笑了 ← 错误!没有用|||分开 +{{user}}: 我也想你 ← 错误!禁止替用户发言`; -// 世界书名称前缀(用于生�?【可乐】和xx的聊�?格式�? +// 世界书名称前缀(用于生成"【可乐】和xx的聊天"格式) export const LOREBOOK_NAME_PREFIX = '【可乐】和'; export const LOREBOOK_NAME_SUFFIX = '的聊天'; -// 生成世界书名�? +// 生成世界书名称 export function generateLorebookName(contactName) { return `${LOREBOOK_NAME_PREFIX}${contactName}${LOREBOOK_NAME_SUFFIX}`; } @@ -316,8 +353,8 @@ export function getUserStickers(settings = getSettings()) { // 解析 标签,替换为图片 HTML export function parseMemeTag(text) { if (!text || typeof text !== 'string') return text; - // 匹配 任意描述+文件ID.扩展�?/meme>,只捕获文件ID部分 - // 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符(�?! ? 等)的表情名�? + // 匹配 任意描述+文件ID.扩展名,只捕获文件ID部分 + // 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符(如 ! ? 等)的表情名称 return text.replace(/<\s*meme\s*>.*?([a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif))\s*<\s*\/\s*meme\s*>/gi, (match, fileId) => { return `表情包`; }); @@ -329,11 +366,11 @@ export function hasMemeTag(text) { return /\s*.+?\s*<\/meme>/i.test(text); } -// 智能分割AI消息:处�?||| 分隔符,并将 meme/语音/照片/音乐 标签与其他文字分开 +// 智能分割AI消息:处理 ||| 分隔符,并将 meme/语音/照片/音乐 标签与其他文字分开 export function splitAIMessages(response) { if (!response || typeof response !== 'string') return []; - // 第一步:�?||| 分隔 + // 第一步:用 ||| 分隔 const parts = response.split('|||').map(m => m.trim()).filter(m => m); // 第二步:对每个部分检查是否包含需要分割的特殊标签 @@ -384,13 +421,13 @@ export function splitAIMessages(response) { specialTags.push({ tag: match[0], index: match.index }); } - // 查找音乐标签(带冒号格式�? + // 查找音乐标签(带冒号格式) const musicRegexLocal1 = new RegExp(musicRegexWithColon.source, 'g'); while ((match = musicRegexLocal1.exec(part)) !== null) { specialTags.push({ tag: match[0], index: match.index }); } - // 查找音乐标签(无冒号格式�? + // 查找音乐标签(无冒号格式) const musicRegexLocal2 = new RegExp(musicRegexNoColon.source, 'g'); while ((match = musicRegexLocal2.exec(part)) !== null) { // 避免重复匹配(如果已经被带冒号的匹配到) @@ -415,7 +452,7 @@ export function splitAIMessages(response) { specialTags.push({ tag: match[0], index: match.index }); } - // 如果没有特殊标签,直接添�? + // 如果没有特殊标签,直接添加 if (specialTags.length === 0) { result.push(part); continue; @@ -424,7 +461,7 @@ export function splitAIMessages(response) { // 调试日志 console.log('[可乐] splitAIMessages 分割:', { part, specialTags }); - // 按位置排�? + // 按位置排序 specialTags.sort((a, b) => a.index - b.index); // 分割消息 @@ -440,7 +477,7 @@ export function splitAIMessages(response) { lastEnd = index + tag.length; } - // 添加最后一个标签后的文�? + // 添加最后一个标签后的文字 if (lastEnd < part.length) { const after = part.substring(lastEnd).trim(); if (after) result.push(after); @@ -467,7 +504,7 @@ function applyDefaults(target, defaults) { } } -// 初始化设�? +// 初始化设置 export function loadSettings() { extension_settings[extensionName] = extension_settings[extensionName] || {}; const settings = extension_settings[extensionName]; @@ -485,8 +522,8 @@ export function loadSettings() { } if (settings.userPersona) delete settings.userPersona; - // 迁移:旧�?aiStickers -> stickers(“添加的单个表情”) - // 说明:如果用户已经有自己�?stickers,则不再合并�?aiStickers(避免把旧默�?catbox 列表灌进去)�? + // 迁移:旧的 aiStickers -> stickers("添加的单个表情") + // 说明:如果用户已经有自己的 stickers,则不再合并旧 aiStickers(避免把旧默认 catbox 列表灌进去)。 const hasUserStickers = Array.isArray(settings.stickers) && settings.stickers.some(s => typeof s?.url === 'string' && s.url.trim()); @@ -517,7 +554,7 @@ export function loadSettings() { if (!Array.isArray(settings.stickers)) settings.stickers = []; - // 迁移:旧�?aiStickersEnabled -> userStickersEnabled + // 迁移:旧的 aiStickersEnabled -> userStickersEnabled if (settings.aiStickersEnabled !== undefined) { if (settings.userStickersEnabled === undefined) { settings.userStickersEnabled = settings.aiStickersEnabled; diff --git a/contacts.js b/contacts.js index 8637781..e79deb9 100644 --- a/contacts.js +++ b/contacts.js @@ -2,10 +2,11 @@ * 联系人管理 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave, saveNow } from './save-manager.js'; import { getSettings, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js'; import { generateContactsList } from './ui.js'; import { showToast } from './toast.js'; +import { selectAndCrop } from './cropper.js'; // 当前换头像的联系人索引 let pendingAvatarContactIndex = -1; @@ -40,7 +41,7 @@ export function addContact(characterData) { customHakimiBreakLimit: false }); - saveSettingsDebounced(); + requestSave(); refreshContactsList(); return true; } @@ -65,7 +66,7 @@ export function deleteContact(index) { deleteContactLorebooks(contact); settings.contacts.splice(index, 1); - saveSettingsDebounced(); + saveNow(); refreshContactsList(); } } @@ -109,7 +110,7 @@ export function deleteGroupChat(groupIndex) { deleteGroupLorebooks(group, settings); groupChats.splice(groupIndex, 1); - saveSettingsDebounced(); + requestSave(); refreshContactsList(); // 同时刷新聊天列表 import('./ui.js').then(m => m.refreshChatList()); @@ -142,41 +143,21 @@ function deleteGroupLorebooks(group, settings) { // 更换角色头像(在设置弹窗中使用) 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; + // 使用裁剪器选择并裁剪头像(1:1比例) + selectAndCrop(1, (croppedImage) => { + if (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(); + const settings = getSettings(); + if (settings.contacts[pendingAvatarContactIndex]) { + settings.contacts[pendingAvatarContactIndex].avatar = croppedImage; + requestSave(); + refreshContactsList(); + // 更新弹窗中的头像预览 + updateContactSettingsAvatar(pendingAvatarContactIndex); + showToast('角色头像已更换'); + } + }); } // 更新弹窗中的头像预览 @@ -262,7 +243,7 @@ export function saveContactSettings() { // 保存哈基米破限 contact.customHakimiBreakLimit = document.getElementById('wechat-contact-hakimi-toggle')?.classList.contains('on') || false; - saveSettingsDebounced(); + requestSave(); showToast('角色设置已保存'); // 关闭弹窗 @@ -450,7 +431,7 @@ function deleteContactDirect(index) { deleteContactLorebooks(contact); settings.contacts.splice(index, 1); - saveSettingsDebounced(); + requestSave(); refreshContactsList(); } diff --git a/cropper.js b/cropper.js new file mode 100644 index 0000000..cf3f0ab --- /dev/null +++ b/cropper.js @@ -0,0 +1,390 @@ +/** + * 通用图片裁剪器模块 + * 支持不同比例的裁剪(头像1:1, 封面16:9等) + */ + +import { showToast } from './toast.js'; + +// 裁剪器状态 +let cropperState = { + image: null, + canvas: null, + ctx: null, + imageWidth: 0, + imageHeight: 0, + imageX: 0, + imageY: 0, + cropBox: { x: 0, y: 0, width: 100, height: 100 }, + isDragging: false, + isResizing: false, + dragStart: { x: 0, y: 0 }, + boxStart: { x: 0, y: 0, width: 0, height: 0 }, + resizeHandle: null, + aspectRatio: 1, // 宽高比 + callback: null // 裁剪完成回调 +}; + +/** + * 初始化裁剪器事件 + */ +export function initCropper() { + // 取消按钮 + 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 }); + }); +} + +/** + * 打开裁剪器 + * @param {string} imageSrc - 图片数据URL + * @param {number} aspectRatio - 宽高比 (例如 1 表示 1:1, 16/9 表示 16:9) + * @param {function} callback - 裁剪完成回调函数,接收裁剪后的base64图片 + */ +export function openCropper(imageSrc, aspectRatio = 1, callback = null) { + 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; + + cropperState.aspectRatio = aspectRatio; + cropperState.callback = callback; + + const img = new Image(); + img.onload = () => { + cropperState.image = img; + cropperState.canvas = canvas; + cropperState.ctx = canvas.getContext('2d'); + + // 计算适应容器的尺寸 + const containerWidth = container.clientWidth || 320; + const containerHeight = container.clientHeight || 320; + + 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); + + // 初始化裁剪框(居中,保持比例) + initCropBox(); + + modal.classList.remove('hidden'); + updateCropBoxUI(); + }; + img.src = imageSrc; +} + +/** + * 根据宽高比初始化裁剪框 + */ +function initCropBox() { + const { imageWidth, imageHeight, aspectRatio } = cropperState; + + let boxWidth, boxHeight; + + if (aspectRatio >= 1) { + // 宽 >= 高的比例(如 1:1, 16:9) + boxWidth = Math.min(imageWidth * 0.8, imageHeight * 0.8 * aspectRatio); + boxHeight = boxWidth / aspectRatio; + } else { + // 高 > 宽的比例(如 9:16) + boxHeight = Math.min(imageHeight * 0.8, imageWidth * 0.8 / aspectRatio); + boxWidth = boxHeight * aspectRatio; + } + + // 确保裁剪框不超过图片边界 + boxWidth = Math.min(boxWidth, imageWidth); + boxHeight = Math.min(boxHeight, imageHeight); + + cropperState.cropBox = { + x: (imageWidth - boxWidth) / 2, + y: (imageHeight - boxHeight) / 2, + width: boxWidth, + height: boxHeight + }; +} + +/** + * 更新裁剪框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'); + if (!container) return; + + // 计算偏移(使裁剪框相对于容器居中的canvas) + const offsetX = (container.clientWidth - canvas.width) / 2; + const offsetY = (container.clientHeight - canvas.height) / 2; + + 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 { aspectRatio } = cropperState; + const handle = cropperState.resizeHandle; + let { x, y, width, height } = cropperState.boxStart; + + // 根据拖动的角计算新尺寸 + let delta; + + switch (handle) { + case 'se': // 右下角 + delta = Math.max(dx, dy / aspectRatio); + width = Math.max(50, width + delta); + height = width / aspectRatio; + break; + case 'sw': // 左下角 + delta = Math.max(-dx, dy / aspectRatio); + width = Math.max(50, width + delta); + height = width / aspectRatio; + x = cropperState.boxStart.x + cropperState.boxStart.width - width; + break; + case 'ne': // 右上角 + delta = Math.max(dx, -dy / aspectRatio); + width = Math.max(50, width + delta); + height = width / aspectRatio; + y = cropperState.boxStart.y + cropperState.boxStart.height - height; + break; + case 'nw': // 左上角 + delta = Math.max(-dx, -dy / aspectRatio); + width = Math.max(50, width + delta); + height = width / aspectRatio; + x = cropperState.boxStart.x + cropperState.boxStart.width - width; + y = cropperState.boxStart.y + cropperState.boxStart.height - height; + break; + } + + // 限制边界 + if (x < 0) { + width = width + x; + height = width / aspectRatio; + x = 0; + } + if (y < 0) { + height = height + y; + width = height * aspectRatio; + y = 0; + } + if (x + width > cropperState.imageWidth) { + width = cropperState.imageWidth - x; + height = width / aspectRatio; + } + if (y + height > cropperState.imageHeight) { + height = cropperState.imageHeight - y; + width = height * aspectRatio; + } + + // 最小尺寸限制 + if (width < 50 || height < 50) return; + + cropperState.cropBox = { x, y, width, height }; + updateCropBoxUI(); +} + +function handleCropperMouseUp() { + cropperState.isDragging = false; + cropperState.isResizing = false; +} + +function handleCropperTouchEnd() { + cropperState.isDragging = false; + cropperState.isResizing = false; +} + +/** + * 关闭裁剪器 + */ +export function closeCropper() { + document.getElementById('wechat-cropper-modal')?.classList.add('hidden'); + cropperState.image = null; + cropperState.callback = null; +} + +/** + * 确认裁剪 + */ +function confirmCrop() { + if (!cropperState.image || !cropperState.canvas) { + showToast('裁剪失败', 'info'); + 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 croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + const croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + cropperState.image, + cropX, cropY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + const croppedDataUrl = croppedCanvas.toDataURL('image/jpeg', 0.9); + + // 调用回调 + if (cropperState.callback) { + cropperState.callback(croppedDataUrl); + } + + closeCropper(); +} + +/** + * 便捷方法:选择文件并打开裁剪器 + * @param {number} aspectRatio - 宽高比 + * @param {function} callback - 裁剪完成回调 + */ +export function selectAndCrop(aspectRatio, callback) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + openCropper(event.target.result, aspectRatio, callback); + }; + reader.readAsDataURL(file); + } + }; + input.click(); +} diff --git a/emoji-panel.js b/emoji-panel.js index 0dcceeb..5f586b5 100644 --- a/emoji-panel.js +++ b/emoji-panel.js @@ -2,7 +2,7 @@ * 表情面板功能 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave } from './save-manager.js'; import { getSettings } from './config.js'; import { showToast } from './toast.js'; import { isInGroupChat } from './group-chat.js'; @@ -244,7 +244,7 @@ function addStickersFromInput(inputs) { // 检查是否已存在 const exists = settings.stickers.some(s => s.url === url); if (exists) { - showToast(`已存在: ${name}`, '🧊'); + showToast(`已存在: ${name}`, 'info'); continue; } @@ -260,7 +260,7 @@ function addStickersFromInput(inputs) { } if (addedCount > 0) { - saveSettingsDebounced(); + requestSave(); refreshEmojiGrid(); showToast(`已添加 ${addedCount} 个表情`); } @@ -299,7 +299,7 @@ function addStickerFromFile() { } if (addedCount > 0) { - saveSettingsDebounced(); + requestSave(); refreshEmojiGrid(); showToast(`已添加 ${addedCount} 个表情`); } @@ -369,7 +369,7 @@ function deleteSticker(index) { if (index >= 0 && index < stickers.length) { stickers.splice(index, 1); - saveSettingsDebounced(); + requestSave(); refreshEmojiGrid(); showToast('表情已删除'); } @@ -392,7 +392,7 @@ export function initEmojiPanel() { const tabName = tab.dataset.tab; if (tabName === 'search') { - showToast('搜索功能开发中...', '🧊'); + showToast('搜索功能开发中...', 'info'); } }); }); diff --git a/favorites.js b/favorites.js index 530180d..d110c3a 100644 --- a/favorites.js +++ b/favorites.js @@ -2,7 +2,7 @@ * 收藏/世界书管理 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave } from './save-manager.js'; import { world_names, loadWorldInfo, saveWorldInfo } from '../../../world-info.js'; import { getSettings } from './config.js'; import { escapeHtml } from './utils.js'; @@ -178,7 +178,7 @@ export function toggleFavoritesItem(type, index, enabled) { } } - saveSettingsDebounced(); + requestSave(); } // 移除收藏项 @@ -190,7 +190,7 @@ export function removeFavoritesItem(type, index) { if (!persona) return; if (confirm(`确定移除「${persona.name || '用户设定'}」?`)) { settings.userPersonas.splice(index, 1); - saveSettingsDebounced(); + requestSave(); refreshFavoritesList(); showToast('已移除'); } @@ -199,7 +199,7 @@ export function removeFavoritesItem(type, index) { if (!lorebook) return; if (confirm(`确定移除「${lorebook.name}」?`)) { settings.selectedLorebooks.splice(index, 1); - saveSettingsDebounced(); + requestSave(); refreshFavoritesList(); showToast('已移除'); } @@ -381,7 +381,7 @@ function showNewPersonaModal() { settings.userPersonas.push({ name, content, enabled: true, addedTime: timeStr }); - saveSettingsDebounced(); + requestSave(); modal.remove(); refreshFavoritesList(); }); @@ -412,7 +412,7 @@ function bindPersonaPanelEvents(panel, personaIdx) { if (settings.userPersonas?.[personaIdx]) { settings.userPersonas[personaIdx].name = name; settings.userPersonas[personaIdx].content = content; - saveSettingsDebounced(); + requestSave(); showToast('已保存'); refreshFavoritesList(); closeUserPersonaDetail(); @@ -443,7 +443,7 @@ function bindPersonaPanelEvents(panel, personaIdx) { panel.querySelector('#wechat-persona-delete').addEventListener('click', () => { if (confirm('确定删除此用户设定?')) { settings.userPersonas.splice(personaIdx, 1); - saveSettingsDebounced(); + requestSave(); closeUserPersonaDetail(); refreshFavoritesList(); } @@ -477,7 +477,7 @@ async function syncPersonaToTavern(name, content) { // 保存设置 if (typeof SillyTavern !== 'undefined' && SillyTavern.saveSettingsDebounced) { - await SillyTavern.saveSettingsDebounced(); + await SillyTavern.requestSave(); } // 尝试执行同步命令 @@ -628,7 +628,7 @@ function bindLorebookPanelEvents(panel, lorebook, lorebookIdx) { const settings = getSettings(); if (settings.selectedLorebooks?.[lorebookIdx]?.entries?.[entryIdx]) { settings.selectedLorebooks[lorebookIdx].entries[entryIdx].enabled = toggle.checked; - saveSettingsDebounced(); + requestSave(); // 同步到酒馆 await syncLorebookToTavern(lorebook.name, lorebookIdx); } @@ -695,7 +695,7 @@ function bindLorebookPanelEvents(panel, lorebook, lorebookIdx) { entry.comment = comment; entry.keys = keys; entry.content = content; - saveSettingsDebounced(); + requestSave(); // 同步到酒馆 btn.disabled = true; @@ -735,7 +735,7 @@ function bindLorebookPanelEvents(panel, lorebook, lorebookIdx) { if (confirm(`确定移除「${lorebook.name}」?`)) { const settings = getSettings(); settings.selectedLorebooks.splice(lorebookIdx, 1); - saveSettingsDebounced(); + requestSave(); closeLorebookDetail(); refreshFavoritesList(); } @@ -871,7 +871,7 @@ export async function refreshLorebookFromTavern(name, lorebookIdx) { if (settings.selectedLorebooks?.[lorebookIdx]) { settings.selectedLorebooks[lorebookIdx].entries = entries; settings.selectedLorebooks[lorebookIdx].lastUpdated = new Date().toISOString(); - saveSettingsDebounced(); + requestSave(); } } @@ -992,7 +992,7 @@ export function showAddPersonaPanel() { const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')}`; settings.userPersonas.push({ name: name || '用户设定', content, enabled: true, addedTime: timeStr }); - saveSettingsDebounced(); + requestSave(); modal.remove(); refreshFavoritesList(); @@ -1079,7 +1079,7 @@ export async function addLorebookToFavorites(name) { fromCharacter: false // 标记为全局世界书 }); - saveSettingsDebounced(); + requestSave(); refreshFavoritesList('global'); showToast(`已导入「${name}」为全局世界书`); } catch (err) { @@ -1171,7 +1171,7 @@ export async function syncCharacterBookToTavern(charData) { }); } - saveSettingsDebounced(); + requestSave(); // 尝试同步到酒馆世界书系统 if (typeof saveWorldInfo === 'function') { diff --git a/group-chat.js b/group-chat.js index 95c5e6a..efba3c8 100644 --- a/group-chat.js +++ b/group-chat.js @@ -2,7 +2,7 @@ * 群聊功能 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave, saveNow } from './save-manager.js'; import { getContext } from '../../../extensions.js'; import { getSettings, SUMMARY_MARKER_PREFIX, getUserStickers, parseMemeTag, MEME_PROMPT_TEMPLATE, splitAIMessages } from './config.js'; import { showToast } from './toast.js'; @@ -26,9 +26,28 @@ const GROUP_CHAT_MAX_AI_MEMBERS = 3; // 检查群聊记录是否需要总结提醒 function checkGroupSummaryReminder(groupChat) { if (!groupChat || !groupChat.chatHistory) return; - const count = groupChat.chatHistory.length; - if (count >= GROUP_CHAT_SUMMARY_REMINDER_THRESHOLD) { - showToast(`群聊记录已达${count}条,建议总结`, '⚠️', 4000); + + // 查找最后一个总结标记的位置 + let lastMarkerIndex = -1; + for (let i = groupChat.chatHistory.length - 1; i >= 0; i--) { + if (groupChat.chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX) || groupChat.chatHistory[i].isMarker) { + lastMarkerIndex = i; + break; + } + } + + // 计算标记之后的消息数量(不含标记本身) + const newMsgCount = groupChat.chatHistory.slice(lastMarkerIndex + 1).filter( + m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX) && !m.isMarker + ).length; + + // 只在刚好达到阈值时提醒一次(通过标记位避免重复提醒) + if (newMsgCount >= GROUP_CHAT_SUMMARY_REMINDER_THRESHOLD && !groupChat._summaryReminderShown) { + groupChat._summaryReminderShown = true; + showToast(`群聊记录已达${newMsgCount}条,建议总结`, '⚠️', 2500); + } else if (newMsgCount < GROUP_CHAT_SUMMARY_REMINDER_THRESHOLD) { + // 如果消息数低于阈值(可能是总结后),重置标记 + groupChat._summaryReminderShown = false; } } @@ -222,7 +241,7 @@ export function enforceGroupChatMemberLimit(groupChat, { toast = false } = {}) { const trimmed = memberIds.slice(0, GROUP_CHAT_MAX_AI_MEMBERS); groupChat.memberIds = trimmed; - saveSettingsDebounced(); + requestSave(); if (toast) { showToast(`群聊最多 ${GROUP_CHAT_MAX_AI_MEMBERS} 个成员(+你=4),已自动裁剪`, '⚠️'); @@ -400,7 +419,7 @@ export function showGroupCreateModal() { const apiKey = keyInput?.value?.trim(); if (!apiUrl) { - showToast('请先填写API地址', '🧊'); + showToast('请先填写API地址', 'info'); return; } @@ -417,7 +436,7 @@ export function showGroupCreateModal() { models.map(m => ``).join(''); showToast(`获取到 ${models.length} 个模型`); } else { - showToast('未找到可用模型', '🧊'); + showToast('未找到可用模型', 'info'); } } catch (err) { console.error('[可乐] 获取模型失败:', err); @@ -446,7 +465,7 @@ export function showGroupCreateModal() { // 更新图标 apiToggle.textContent = contact.useCustomApi ? '⚙️' : '▼'; - saveSettingsDebounced(); + requestSave(); }; item.querySelector('.wechat-group-api-url')?.addEventListener('change', saveApiConfig); @@ -462,7 +481,7 @@ export function showGroupCreateModal() { hakimiToggle.classList.toggle('on'); contact.customHakimiBreakLimit = hakimiToggle.classList.contains('on'); - saveSettingsDebounced(); + requestSave(); }); }); } @@ -561,7 +580,7 @@ export function createGroupChat() { if (!settings.groupChats) settings.groupChats = []; settings.groupChats.push(groupChat); - saveSettingsDebounced(); + requestSave(); refreshChatList(); closeGroupCreateModal(); @@ -640,6 +659,69 @@ function renderGroupChatHistory(groupChat, members, chatHistory) { const isSticker = msg.isSticker === true; const isPhoto = msg.isPhoto === true; const isMusic = msg.isMusic === true; + const isGroupRedPacket = msg.isGroupRedPacket === true; + const isGroupTransfer = msg.isGroupTransfer === true; + + // 群红包消息 + if (isGroupRedPacket && msg.groupRedPacketInfo) { + const rpInfo = msg.groupRedPacketInfo; + const isDesignated = rpInfo.type === 'designated'; + const isClaimed = rpInfo.status === 'claimed' || (rpInfo.claimedBy && rpInfo.claimedBy.length >= rpInfo.count); + const statusClass = isClaimed ? 'claimed' : ''; + const designatedLabel = isDesignated ? `
给${(rpInfo.targetMemberNames || []).join('、') || '指定成员'}的红包
` : ''; + + if (msg.role === 'user') { + html += ` +
+
${getUserAvatarHTML()}
+
+
+
+ +
+
+
${escapeHtml(rpInfo.message || '恭喜发财,大吉大利')}
+ ${designatedLabel} +
${isClaimed ? '已领完' : ''}
+
+
+ +
+
+ `; + } + return; + } + + // 群转账消息 + if (isGroupTransfer && msg.groupTransferInfo) { + const tfInfo = msg.groupTransferInfo; + const statusText = tfInfo.status === 'received' ? '已收款' : + tfInfo.status === 'refunded' ? '已退还' : '待收款'; + const statusClass = tfInfo.status || 'pending'; + + if (msg.role === 'user') { + html += ` +
+
${getUserAvatarHTML()}
+
+
+
+ ¥ +
+
+
¥${tfInfo.amount.toFixed(2)}
+
向${escapeHtml(tfInfo.targetMemberName)}转账
+
${escapeHtml(tfInfo.description) || '转账'}
+
+
${statusText}
+
+
+
+ `; + } + return; + } if (msg.role === 'user') { // 用户消息 @@ -2054,7 +2136,7 @@ async function syncGroupMembersLorebooks(members, settings) { } if (hasChanges) { - saveSettingsDebounced(); + requestSave(); } } @@ -2132,7 +2214,7 @@ export async function sendGroupMessage(messageText, isMultipleMessages = false, } // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 显示打字指示器 showGroupTypingIndicator(members[0]?.name, members[0]?.id); @@ -2278,7 +2360,7 @@ export async function sendGroupMessage(messageText, isMultipleMessages = false, } groupChat.lastMessageTime = Date.now(); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkGroupSummaryReminder(groupChat); @@ -2287,7 +2369,7 @@ export async function sendGroupMessage(messageText, isMultipleMessages = false, console.error('[可乐] 群聊 AI 调用失败:', err); appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false); - saveSettingsDebounced(); + requestSave(); } } @@ -2354,7 +2436,7 @@ export async function sendGroupStickerMessage(stickerUrl, description = '') { groupChat.lastMessageTime = msgTimestamp; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 显示消息 appendGroupStickerMessage('user', stickerUrl); @@ -2410,14 +2492,14 @@ export async function sendGroupStickerMessage(stickerUrl, description = '') { } groupChat.lastMessageTime = Date.now(); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkGroupSummaryReminder(groupChat); } catch (err) { hideGroupTypingIndicator(); console.error('[可乐] 群聊表情消息 AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false); } @@ -2515,7 +2597,7 @@ export async function sendGroupPhotoMessage(description) { groupChat.lastMessageTime = msgTimestamp; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 显示消息 appendGroupPhotoMessage('user', description); @@ -2565,14 +2647,14 @@ export async function sendGroupPhotoMessage(description) { } groupChat.lastMessageTime = Date.now(); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkGroupSummaryReminder(groupChat); } catch (err) { hideGroupTypingIndicator(); console.error('[可乐] 群聊照片消息 AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false); } @@ -2751,7 +2833,7 @@ export async function sendGroupBatchMessages(messages) { groupChat.lastMessageTime = msgTimestamp; // 立即保存,确保用户消息不会丢失 - saveSettingsDebounced(); + saveNow(); // 第二步:调用AI(一次性) showGroupTypingIndicator(members[0]?.name, members[0]?.id); @@ -2798,14 +2880,14 @@ export async function sendGroupBatchMessages(messages) { } groupChat.lastMessageTime = Date.now(); - saveSettingsDebounced(); + requestSave(); refreshChatList(); checkGroupSummaryReminder(groupChat); } catch (err) { hideGroupTypingIndicator(); console.error('[可乐] 群聊批量消息 AI 调用失败:', err); - saveSettingsDebounced(); + requestSave(); refreshChatList(); appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null); } diff --git a/group-red-packet.js b/group-red-packet.js new file mode 100644 index 0000000..a2b85a2 --- /dev/null +++ b/group-red-packet.js @@ -0,0 +1,1733 @@ +/** + * 群聊红包/转账功能模块 + * 支持拼手气红包、指定成员红包、群聊转账 + */ + +import { getSettings } from './config.js'; +import { requestSave } from './save-manager.js'; +import { showToast } from './toast.js'; +import { escapeHtml, sleep } from './utils.js'; +import { refreshChatList, getUserAvatarHTML } from './ui.js'; +import { deductFromWallet, addToWallet, getWalletBalance, generateRedPacketId } from './red-packet.js'; +import { generateTransferId } from './transfer.js'; +import { getCurrentGroupIndex, enforceGroupChatMemberLimit, appendGroupMessage, showGroupTypingIndicator, hideGroupTypingIndicator } from './group-chat.js'; +import { buildSystemPrompt } from './ai.js'; + +// ============ 状态变量 ============ + +// 群红包状态 +let groupRedPacketType = 'random'; // 'random' | 'designated' +let groupRedPacketAmount = ''; +let groupRedPacketCount = ''; +let groupRedPacketMessage = '恭喜发财,大吉大利'; +let groupRedPacketSelectedMembers = []; // 指定成员红包的目标成员ID列表 + +// 群转账状态 +let groupTransferAmount = ''; +let groupTransferDescription = ''; +let groupTransferTargetMemberId = null; + +// 待领取的群红包 +let pendingGroupRedPacket = null; +let pendingGroupRedPacketIndex = -1; + +// ============ 工具函数 ============ + +function getTimeStr() { + const now = new Date(); + return `${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')}`; +} + +/** + * 随机分配红包金额(拼手气) + * @param {number} totalAmount 总金额 + * @param {number} count 红包个数 + * @returns {number[]} 每个红包的金额数组 + */ +function distributeRandomAmounts(totalAmount, count) { + if (count <= 0 || totalAmount <= 0) return []; + if (count === 1) return [totalAmount]; + + const amounts = []; + let remaining = Math.round(totalAmount * 100); // 转为分,避免浮点数精度问题 + const minAmount = 1; // 最小1分 + + for (let i = 0; i < count - 1; i++) { + const maxForThis = remaining - (count - i - 1) * minAmount; + if (maxForThis <= minAmount) { + amounts.push(minAmount); + remaining -= minAmount; + continue; + } + + // 20% 概率只给 0.01 元(1分) + if (Math.random() < 0.2) { + amounts.push(minAmount); + remaining -= minAmount; + continue; + } + + // 正常随机分配(使用二倍均值法变体) + const avgRemaining = remaining / (count - i); + const maxRandom = Math.min(maxForThis, Math.floor(avgRemaining * 2)); + const randomAmount = Math.max(minAmount, Math.floor(Math.random() * maxRandom)); + amounts.push(randomAmount); + remaining -= randomAmount; + } + + // 最后一个红包拿走剩余金额 + amounts.push(remaining); + + // 打乱顺序 + for (let i = amounts.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [amounts[i], amounts[j]] = [amounts[j], amounts[i]]; + } + + // 转回元 + return amounts.map(a => a / 100); +} + +// ============ 群红包类型选择页面 ============ + +/** + * 显示群红包类型选择页面 + */ +export function showGroupRedPacketTypePage() { + const page = document.getElementById('wechat-group-rp-type-page'); + if (!page) { + createGroupRedPacketPages(); + } + + // 重置状态 + groupRedPacketType = 'random'; + groupRedPacketAmount = ''; + groupRedPacketCount = ''; + groupRedPacketMessage = '恭喜发财,大吉大利'; + groupRedPacketSelectedMembers = []; + + document.getElementById('wechat-group-rp-type-page')?.classList.remove('hidden'); +} + +/** + * 隐藏群红包类型选择页面 + */ +export function hideGroupRedPacketTypePage() { + document.getElementById('wechat-group-rp-type-page')?.classList.add('hidden'); +} + +// ============ 拼手气红包页面 ============ + +/** + * 显示拼手气红包页面 + */ +export function showGroupRandomRedPacketPage() { + hideGroupRedPacketTypePage(); + groupRedPacketType = 'random'; + groupRedPacketAmount = ''; + groupRedPacketCount = ''; + + const page = document.getElementById('wechat-group-random-rp-page'); + if (page) { + page.classList.remove('hidden'); + updateGroupRandomRedPacketDisplay(); + } +} + +/** + * 隐藏拼手气红包页面 + */ +export function hideGroupRandomRedPacketPage() { + document.getElementById('wechat-group-random-rp-page')?.classList.add('hidden'); + document.getElementById('wechat-group-rp-keyboard')?.classList.add('hidden'); +} + +/** + * 更新拼手气红包显示 + */ +function updateGroupRandomRedPacketDisplay() { + const amountEl = document.getElementById('wechat-group-rp-amount-value'); + const countEl = document.getElementById('wechat-group-rp-count-value'); + const totalEl = document.getElementById('wechat-group-rp-total-display'); + + if (amountEl) { + amountEl.textContent = groupRedPacketAmount || '0.00'; + } + if (countEl) { + countEl.textContent = groupRedPacketCount || '0'; + } + if (totalEl) { + const amount = parseFloat(groupRedPacketAmount) || 0; + totalEl.textContent = '¥' + amount.toFixed(2); + } +} + +// ============ 指定成员红包页面 ============ + +/** + * 显示指定成员红包页面 + */ +export function showGroupDesignatedRedPacketPage() { + hideGroupRedPacketTypePage(); + groupRedPacketType = 'designated'; + groupRedPacketAmount = ''; + groupRedPacketSelectedMembers = []; + + const settings = getSettings(); + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const { memberIds } = enforceGroupChatMemberLimit(groupChat); + const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean); + + // 渲染成员列表 + const listContainer = document.getElementById('wechat-group-designated-member-list'); + if (listContainer) { + listContainer.innerHTML = members.map(member => { + const firstChar = member.name?.charAt(0) || '?'; + const avatarHtml = member.avatar + ? `` + : firstChar; + + return ` +
+
+ +
+
${avatarHtml}
+
${escapeHtml(member.name)}
+
+ `; + }).join(''); + + // 绑定点击事件 + listContainer.querySelectorAll('.wechat-group-designated-member-item').forEach(item => { + item.addEventListener('click', (e) => { + const checkbox = item.querySelector('input[type="checkbox"]'); + if (e.target !== checkbox) { + checkbox.checked = !checkbox.checked; + } + updateGroupDesignatedSelection(); + }); + }); + } + + document.getElementById('wechat-group-designated-rp-page')?.classList.remove('hidden'); + updateGroupDesignatedRedPacketDisplay(); +} + +/** + * 更新指定成员选择 + */ +function updateGroupDesignatedSelection() { + const checkboxes = document.querySelectorAll('#wechat-group-designated-member-list input[type="checkbox"]:checked'); + groupRedPacketSelectedMembers = Array.from(checkboxes).map(cb => cb.dataset.memberId); + + const countEl = document.getElementById('wechat-group-designated-count'); + if (countEl) { + countEl.textContent = groupRedPacketSelectedMembers.length; + } +} + +/** + * 隐藏指定成员红包页面 + */ +export function hideGroupDesignatedRedPacketPage() { + document.getElementById('wechat-group-designated-rp-page')?.classList.add('hidden'); + document.getElementById('wechat-group-designated-keyboard')?.classList.add('hidden'); +} + +/** + * 更新指定成员红包显示 + */ +function updateGroupDesignatedRedPacketDisplay() { + const amountEl = document.getElementById('wechat-group-designated-amount-value'); + const countEl = document.getElementById('wechat-group-designated-count'); + const totalEl = document.getElementById('wechat-group-designated-total-display'); + + const amount = parseFloat(groupRedPacketAmount) || 0; + const count = groupRedPacketSelectedMembers.length; + + if (amountEl) { + amountEl.textContent = groupRedPacketAmount || '0.00'; + } + if (countEl) { + countEl.textContent = count; + } + if (totalEl) { + totalEl.textContent = '¥' + (amount * count).toFixed(2); + } +} + +// ============ 群转账成员选择页面 ============ + +/** + * 显示群转账成员选择页面 + */ +export function showGroupTransferSelectPage() { + groupTransferAmount = ''; + groupTransferDescription = ''; + groupTransferTargetMemberId = null; + + const settings = getSettings(); + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const { memberIds } = enforceGroupChatMemberLimit(groupChat); + const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean); + + // 渲染成员列表 + const listContainer = document.getElementById('wechat-group-transfer-member-list'); + if (listContainer) { + listContainer.innerHTML = members.map(member => { + const firstChar = member.name?.charAt(0) || '?'; + const avatarHtml = member.avatar + ? `` + : firstChar; + + return ` +
+
${avatarHtml}
+
${escapeHtml(member.name)}
+
+ +
+
+ `; + }).join(''); + + // 绑定点击事件 + listContainer.querySelectorAll('.wechat-group-transfer-member-item').forEach(item => { + item.addEventListener('click', () => { + groupTransferTargetMemberId = item.dataset.memberId; + hideGroupTransferSelectPage(); + showGroupTransferAmountPage(); + }); + }); + } + + document.getElementById('wechat-group-transfer-select-page')?.classList.remove('hidden'); +} + +/** + * 隐藏群转账成员选择页面 + */ +export function hideGroupTransferSelectPage() { + document.getElementById('wechat-group-transfer-select-page')?.classList.add('hidden'); +} + +// ============ 群转账金额输入页面 ============ + +/** + * 显示群转账金额输入页面 + */ +export function showGroupTransferAmountPage() { + const settings = getSettings(); + const targetMember = settings.contacts?.find(c => c.id === groupTransferTargetMemberId); + if (!targetMember) { + showToast('请先选择转账对象', 'info'); + return; + } + + // 更新页面标题显示目标成员 + const titleEl = document.getElementById('wechat-group-transfer-target-name'); + if (titleEl) { + titleEl.textContent = `向 ${targetMember.name} 转账`; + } + + groupTransferAmount = ''; + groupTransferDescription = ''; + + document.getElementById('wechat-group-transfer-amount-page')?.classList.remove('hidden'); + updateGroupTransferAmountDisplay(); +} + +/** + * 隐藏群转账金额输入页面 + */ +export function hideGroupTransferAmountPage() { + document.getElementById('wechat-group-transfer-amount-page')?.classList.add('hidden'); + document.getElementById('wechat-group-transfer-keyboard')?.classList.add('hidden'); +} + +/** + * 更新群转账金额显示 + */ +function updateGroupTransferAmountDisplay() { + const amountEl = document.getElementById('wechat-group-transfer-amount-value'); + const displayEl = document.getElementById('wechat-group-transfer-amount-display'); + + const amount = parseFloat(groupTransferAmount) || 0; + + if (amountEl) { + amountEl.textContent = groupTransferAmount || '0.00'; + } + if (displayEl) { + displayEl.textContent = '¥' + amount.toFixed(2); + } +} + +// ============ 键盘处理 ============ + +let currentKeyboardTarget = null; // 'random-amount' | 'random-count' | 'designated-amount' | 'transfer-amount' + +/** + * 显示数字键盘 + */ +export function showGroupKeyboard(target) { + currentKeyboardTarget = target; + + let keyboardId; + if (target === 'random-amount' || target === 'random-count') { + keyboardId = 'wechat-group-rp-keyboard'; + } else if (target === 'designated-amount') { + keyboardId = 'wechat-group-designated-keyboard'; + } else if (target === 'transfer-amount') { + keyboardId = 'wechat-group-transfer-keyboard'; + } + + const keyboard = document.getElementById(keyboardId); + if (keyboard) { + keyboard.classList.remove('hidden'); + } +} + +/** + * 隐藏数字键盘 + */ +export function hideGroupKeyboard() { + document.getElementById('wechat-group-rp-keyboard')?.classList.add('hidden'); + document.getElementById('wechat-group-designated-keyboard')?.classList.add('hidden'); + document.getElementById('wechat-group-transfer-keyboard')?.classList.add('hidden'); + currentKeyboardTarget = null; +} + +/** + * 处理键盘输入 + */ +export function handleGroupKeyboardInput(key) { + if (!currentKeyboardTarget) return; + + let currentValue; + let isCount = currentKeyboardTarget === 'random-count'; + + if (currentKeyboardTarget === 'random-amount') { + currentValue = groupRedPacketAmount; + } else if (currentKeyboardTarget === 'random-count') { + currentValue = groupRedPacketCount; + } else if (currentKeyboardTarget === 'designated-amount') { + currentValue = groupRedPacketAmount; + } else if (currentKeyboardTarget === 'transfer-amount') { + currentValue = groupTransferAmount; + } + + if (key === 'backspace') { + currentValue = currentValue.slice(0, -1); + } else if (key === 'confirm') { + hideGroupKeyboard(); + return; + } else if (key === '.') { + if (isCount) return; // 红包个数不允许小数点 + if (!currentValue.includes('.') && currentValue.length > 0) { + currentValue += '.'; + } + } else { + if (isCount) { + // 红包个数:整数,最多2位 + if (currentValue.length < 2) { + currentValue += key; + } + } else { + // 金额 + const dotIndex = currentValue.indexOf('.'); + if (dotIndex !== -1) { + if (currentValue.length - dotIndex <= 2) { + currentValue += key; + } + } else { + if (currentValue.length < 6) { + currentValue += key; + } + } + } + } + + // 更新状态 + if (currentKeyboardTarget === 'random-amount') { + groupRedPacketAmount = currentValue; + updateGroupRandomRedPacketDisplay(); + } else if (currentKeyboardTarget === 'random-count') { + groupRedPacketCount = currentValue; + updateGroupRandomRedPacketDisplay(); + } else if (currentKeyboardTarget === 'designated-amount') { + groupRedPacketAmount = currentValue; + updateGroupDesignatedRedPacketDisplay(); + } else if (currentKeyboardTarget === 'transfer-amount') { + groupTransferAmount = currentValue; + updateGroupTransferAmountDisplay(); + } +} + +// ============ 密码验证 ============ + +let pendingGroupAction = null; // { type: 'random-rp' | 'designated-rp' | 'transfer', ... } + +/** + * 显示群聊密码输入弹窗 + */ +export function showGroupPasswordModal(actionType, extraData = {}) { + pendingGroupAction = { type: actionType, ...extraData }; + + const modal = document.getElementById('wechat-group-password-modal'); + if (modal) { + modal.classList.remove('hidden'); + // 清空密码 + const dots = modal.querySelectorAll('.wechat-password-dot'); + dots.forEach(dot => dot.classList.remove('filled')); + modal.dataset.password = ''; + } +} + +/** + * 隐藏群聊密码输入弹窗 + */ +export function hideGroupPasswordModal() { + const modal = document.getElementById('wechat-group-password-modal'); + if (modal) { + modal.classList.add('hidden'); + } + pendingGroupAction = null; +} + +/** + * 处理密码输入 + */ +export function handleGroupPasswordInput(key) { + const modal = document.getElementById('wechat-group-password-modal'); + if (!modal) return; + + let password = modal.dataset.password || ''; + + if (key === 'backspace') { + password = password.slice(0, -1); + } else if (password.length < 6) { + password += key; + } + + modal.dataset.password = password; + + // 更新密码点显示 + const dots = modal.querySelectorAll('.wechat-password-dot'); + dots.forEach((dot, index) => { + dot.classList.toggle('filled', index < password.length); + }); + + // 6位密码输入完成,验证 + if (password.length === 6) { + const settings = getSettings(); + const correctPassword = settings.paymentPassword || '666666'; + if (password === correctPassword) { + hideGroupPasswordModal(); + executeGroupAction(); + } else { + showToast('密码错误', 'info'); + modal.dataset.password = ''; + dots.forEach(dot => dot.classList.remove('filled')); + } + } +} + +/** + * 执行群聊操作 + */ +async function executeGroupAction() { + if (!pendingGroupAction) return; + + const actionType = pendingGroupAction.type; + + if (actionType === 'random-rp') { + await sendGroupRandomRedPacket(); + } else if (actionType === 'designated-rp') { + await sendGroupDesignatedRedPacket(); + } else if (actionType === 'transfer') { + await sendGroupTransfer(); + } + + pendingGroupAction = null; +} + +// ============ 发送群红包 ============ + +/** + * 提交拼手气红包(显示密码输入) + */ +export function submitGroupRandomRedPacket() { + const amount = parseFloat(groupRedPacketAmount) || 0; + const count = parseInt(groupRedPacketCount) || 0; + + if (amount <= 0) { + showToast('请输入红包金额', 'info'); + return; + } + if (count <= 0) { + showToast('请输入红包个数', 'info'); + return; + } + if (amount > 200) { + showToast('单个红包最多200元', 'info'); + return; + } + if (amount > getWalletBalance()) { + showToast('余额不足', 'info'); + return; + } + + // 获取祝福语 + const messageInput = document.getElementById('wechat-group-rp-message'); + if (messageInput && messageInput.value.trim()) { + groupRedPacketMessage = messageInput.value.trim(); + } + + showGroupPasswordModal('random-rp'); +} + +/** + * 发送拼手气红包 + */ +async function sendGroupRandomRedPacket() { + const amount = parseFloat(groupRedPacketAmount) || 0; + const count = parseInt(groupRedPacketCount) || 0; + + // 扣款 + const result = deductFromWallet(amount); + if (!result.success) { + showToast(result.message, 'info'); + return; + } + + hideGroupRandomRedPacketPage(); + + const settings = getSettings(); + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const { memberIds } = enforceGroupChatMemberLimit(groupChat); + const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean); + + // 分配金额 + const distributedAmounts = distributeRandomAmounts(amount, count); + + // 创建红包信息 + const rpInfo = { + id: generateRedPacketId(), + type: 'random', + totalAmount: amount, + count: count, + message: groupRedPacketMessage, + senderName: settings.userName || 'User', + distributedAmounts: distributedAmounts, + claimedBy: [], // { memberId, memberName, amount, claimedAt } + status: 'pending', + expireAt: Date.now() + 24 * 60 * 60 * 1000 + }; + + // 保存到聊天记录 + if (!groupChat.chatHistory) groupChat.chatHistory = []; + groupChat.chatHistory.push({ + role: 'user', + content: `[群红包] ${groupRedPacketMessage}`, + time: getTimeStr(), + timestamp: Date.now(), + isGroupRedPacket: true, + groupRedPacketInfo: rpInfo + }); + + // 显示红包消息 + appendGroupRedPacketMessage('user', rpInfo); + requestSave(); + refreshChatList(); + + // AI 领取红包(随机延迟) + await processAIClaimGroupRedPacket(rpInfo, groupChat, members); +} + +/** + * 提交指定成员红包(显示密码输入) + */ +export function submitGroupDesignatedRedPacket() { + const amount = parseFloat(groupRedPacketAmount) || 0; + const count = groupRedPacketSelectedMembers.length; + + if (amount <= 0) { + showToast('请输入红包金额', 'info'); + return; + } + if (count <= 0) { + showToast('请选择接收成员', 'info'); + return; + } + if (amount > 200) { + showToast('单个红包最多200元', 'info'); + return; + } + + const totalAmount = amount * count; + if (totalAmount > getWalletBalance()) { + showToast('余额不足', 'info'); + return; + } + + // 获取祝福语 + const messageInput = document.getElementById('wechat-group-designated-message'); + if (messageInput && messageInput.value.trim()) { + groupRedPacketMessage = messageInput.value.trim(); + } + + showGroupPasswordModal('designated-rp'); +} + +/** + * 发送指定成员红包 + */ +async function sendGroupDesignatedRedPacket() { + const amount = parseFloat(groupRedPacketAmount) || 0; + const count = groupRedPacketSelectedMembers.length; + const totalAmount = amount * count; + + // 扣款 + const settings = getSettings(); + const current = getWalletBalance(); + if (totalAmount > current) { + showToast('余额不足', 'info'); + return; + } + + settings.walletAmount = (current - totalAmount).toFixed(2); + requestSave(); + + hideGroupDesignatedRedPacketPage(); + + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const { memberIds } = enforceGroupChatMemberLimit(groupChat); + const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean); + + // 获取指定成员名称 + const targetMembers = groupRedPacketSelectedMembers.map(id => { + const member = settings.contacts.find(c => c.id === id); + return member?.name || '未知'; + }); + + // 创建红包信息 + const rpInfo = { + id: generateRedPacketId(), + type: 'designated', + totalAmount: totalAmount, + amountPerPerson: amount, + count: count, + message: groupRedPacketMessage, + senderName: settings.userName || 'User', + targetMemberIds: [...groupRedPacketSelectedMembers], + targetMemberNames: targetMembers, + claimedBy: [], + status: 'pending', + expireAt: Date.now() + 24 * 60 * 60 * 1000 + }; + + // 保存到聊天记录 + if (!groupChat.chatHistory) groupChat.chatHistory = []; + groupChat.chatHistory.push({ + role: 'user', + content: `[专属红包] 给${targetMembers.join('、')}的红包`, + time: getTimeStr(), + timestamp: Date.now(), + isGroupRedPacket: true, + groupRedPacketInfo: rpInfo + }); + + // 显示红包消息 + appendGroupRedPacketMessage('user', rpInfo); + requestSave(); + refreshChatList(); + + // AI 领取红包 + await processAIClaimGroupRedPacket(rpInfo, groupChat, members); +} + +// ============ 发送群转账 ============ + +/** + * 提交群转账(显示密码输入) + */ +export function submitGroupTransfer() { + const amount = parseFloat(groupTransferAmount) || 0; + + if (amount <= 0) { + showToast('请输入转账金额', 'info'); + return; + } + if (amount > getWalletBalance()) { + showToast('余额不足', 'info'); + return; + } + + // 获取转账说明 + const descInput = document.getElementById('wechat-group-transfer-description'); + if (descInput && descInput.value.trim()) { + groupTransferDescription = descInput.value.trim(); + } + + showGroupPasswordModal('transfer'); +} + +/** + * 发送群转账 + */ +async function sendGroupTransfer() { + const amount = parseFloat(groupTransferAmount) || 0; + + // 扣款 + const settings = getSettings(); + const current = getWalletBalance(); + if (amount > current) { + showToast('余额不足', 'info'); + return; + } + + settings.walletAmount = (current - amount).toFixed(2); + requestSave(); + + hideGroupTransferAmountPage(); + + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const targetMember = settings.contacts?.find(c => c.id === groupTransferTargetMemberId); + if (!targetMember) return; + + // 创建转账信息 + const tfInfo = { + id: generateTransferId(), + amount: amount, + description: groupTransferDescription || '', + senderName: settings.userName || 'User', + targetMemberId: groupTransferTargetMemberId, + targetMemberName: targetMember.name, + status: 'pending', + receivedAt: null, + refundedAt: null, + expireAt: Date.now() + 24 * 60 * 60 * 1000 + }; + + // 保存到聊天记录 + if (!groupChat.chatHistory) groupChat.chatHistory = []; + groupChat.chatHistory.push({ + role: 'user', + content: `[转账] 向${targetMember.name}发起了一笔转账`, + time: getTimeStr(), + timestamp: Date.now(), + isGroupTransfer: true, + groupTransferInfo: tfInfo + }); + + // 显示转账消息 + appendGroupTransferMessage('user', tfInfo); + requestSave(); + refreshChatList(); + + // AI 收款 + await processAIReceiveGroupTransfer(tfInfo, groupChat, targetMember); +} + +// ============ AI 领取红包 ============ + +/** + * AI 领取群红包 + */ +async function processAIClaimGroupRedPacket(rpInfo, groupChat, members) { + // 随机延迟 2-5 秒 + const claimDelay = 2000 + Math.random() * 3000; + await sleep(claimDelay); + + const settings = getSettings(); + const timeStr = getTimeStr(); + + if (rpInfo.type === 'random') { + // 拼手气红包:按顺序领取 + const availableMembers = members.filter(m => !rpInfo.claimedBy.some(c => c.memberId === m.id)); + const claimCount = Math.min(availableMembers.length, rpInfo.distributedAmounts.length - rpInfo.claimedBy.length); + + for (let i = 0; i < claimCount; i++) { + const member = availableMembers[i]; + const amountIndex = rpInfo.claimedBy.length; + const claimAmount = rpInfo.distributedAmounts[amountIndex]; + + rpInfo.claimedBy.push({ + memberId: member.id, + memberName: member.name, + amount: claimAmount, + claimedAt: Date.now() + }); + + // 更新界面 + updateGroupRedPacketBubbleStatus(rpInfo.id); + + // 显示领取提示 + appendGroupRedPacketClaimNotice(member.name, settings.userName || 'User'); + + // AI 感谢消息 + await sleep(800 + Math.random() * 500); + showGroupTypingIndicator(member.name, member.id); + await sleep(600 + Math.random() * 400); + hideGroupTypingIndicator(); + + try { + const thankMsg = await generateAIThankMessage(member, rpInfo, claimAmount); + if (thankMsg) { + groupChat.chatHistory.push({ + role: 'assistant', + content: thankMsg, + characterName: member.name, + characterId: member.id, + time: timeStr, + timestamp: Date.now() + }); + appendGroupMessage('assistant', thankMsg, member.name, member.id); + } + } catch (e) { + console.error('[可乐] AI感谢红包失败:', e); + } + + if (i < claimCount - 1) { + await sleep(1000 + Math.random() * 1000); + } + } + + // 检查是否全部领完 + if (rpInfo.claimedBy.length >= rpInfo.count) { + rpInfo.status = 'claimed'; + } + } else if (rpInfo.type === 'designated') { + // 指定成员红包:只有指定成员可以领取 + for (const memberId of rpInfo.targetMemberIds) { + const member = members.find(m => m.id === memberId); + if (!member) continue; + if (rpInfo.claimedBy.some(c => c.memberId === memberId)) continue; + + rpInfo.claimedBy.push({ + memberId: member.id, + memberName: member.name, + amount: rpInfo.amountPerPerson, + claimedAt: Date.now() + }); + + // 更新界面 + updateGroupRedPacketBubbleStatus(rpInfo.id); + + // 显示领取提示 + appendGroupRedPacketClaimNotice(member.name, settings.userName || 'User'); + + // AI 感谢消息 + await sleep(800 + Math.random() * 500); + showGroupTypingIndicator(member.name, member.id); + await sleep(600 + Math.random() * 400); + hideGroupTypingIndicator(); + + try { + const thankMsg = await generateAIThankMessage(member, rpInfo, rpInfo.amountPerPerson); + if (thankMsg) { + groupChat.chatHistory.push({ + role: 'assistant', + content: thankMsg, + characterName: member.name, + characterId: member.id, + time: timeStr, + timestamp: Date.now() + }); + appendGroupMessage('assistant', thankMsg, member.name, member.id); + } + } catch (e) { + console.error('[可乐] AI感谢红包失败:', e); + } + + await sleep(1000 + Math.random() * 1000); + } + + // 检查是否全部领完 + if (rpInfo.claimedBy.length >= rpInfo.count) { + rpInfo.status = 'claimed'; + } + } + + requestSave(); + refreshChatList(); +} + +/** + * AI 收取群转账 + */ +async function processAIReceiveGroupTransfer(tfInfo, groupChat, targetMember) { + // 随机延迟 2-5 秒 + const receiveDelay = 2000 + Math.random() * 3000; + await sleep(receiveDelay); + + const settings = getSettings(); + const timeStr = getTimeStr(); + + // 更新转账状态 + tfInfo.status = 'received'; + tfInfo.receivedAt = Date.now(); + + // 更新界面 + updateGroupTransferBubbleStatus(tfInfo.id, 'received'); + + // AI 感谢消息 + await sleep(500); + showGroupTypingIndicator(targetMember.name, targetMember.id); + await sleep(600 + Math.random() * 400); + hideGroupTypingIndicator(); + + try { + const thankMsg = await generateAITransferThankMessage(targetMember, tfInfo); + if (thankMsg) { + groupChat.chatHistory.push({ + role: 'assistant', + content: thankMsg, + characterName: targetMember.name, + characterId: targetMember.id, + time: timeStr, + timestamp: Date.now() + }); + appendGroupMessage('assistant', thankMsg, targetMember.name, targetMember.id); + } + } catch (e) { + console.error('[可乐] AI感谢转账失败:', e); + } + + requestSave(); + refreshChatList(); +} + +// ============ AI 消息生成 ============ + +/** + * 生成 AI 红包感谢消息 + */ +async function generateAIThankMessage(member, rpInfo, claimAmount) { + if (!member.useCustomApi || !member.customApiUrl || !member.customModel) { + // 没有配置独立API,返回简单消息 + return `谢谢红包!抢到了${claimAmount.toFixed(2)}元~`; + } + + try { + const systemPrompt = buildSystemPrompt(member, { allowStickers: false, allowMusicShare: false, allowCallRequests: false }); + const userPrompt = `用户给群里发了一个${rpInfo.totalAmount}元的红包,祝福语是"${rpInfo.message}"。你抢到了${claimAmount.toFixed(2)}元,请自然地表示感谢,不要使用任何特殊格式标签。回复要简短自然(10字以内)。`; + + const chatUrl = member.customApiUrl.replace(/\/+$/, '') + '/chat/completions'; + const headers = { 'Content-Type': 'application/json' }; + if (member.customApiKey) { + headers['Authorization'] = `Bearer ${member.customApiKey}`; + } + + const response = await fetch(chatUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + model: member.customModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 1, + max_tokens: 256 + }) + }); + + if (!response.ok) { + throw new Error('API请求失败'); + } + + const data = await response.json(); + let reply = data.choices?.[0]?.message?.content || ''; + // 取第一条消息 + reply = reply.split('|||')[0].trim(); + // 移除可能的格式标签 + reply = reply.replace(/^\[.*?\]\s*/, '').trim(); + + return reply || `谢谢红包!${claimAmount.toFixed(2)}元~`; + } catch (e) { + console.error('[可乐] AI红包感谢消息生成失败:', e); + return `谢谢红包!抢到了${claimAmount.toFixed(2)}元~`; + } +} + +/** + * 生成 AI 转账感谢消息 + */ +async function generateAITransferThankMessage(member, tfInfo) { + if (!member.useCustomApi || !member.customApiUrl || !member.customModel) { + return `收到转账${tfInfo.amount.toFixed(2)}元,谢谢~`; + } + + try { + const systemPrompt = buildSystemPrompt(member, { allowStickers: false, allowMusicShare: false, allowCallRequests: false }); + const userPrompt = tfInfo.description + ? `用户给你转账了${tfInfo.amount}元,备注是"${tfInfo.description}",请自然地表示感谢,不要使用任何特殊格式标签。回复要简短自然(10字以内)。` + : `用户给你转账了${tfInfo.amount}元,请自然地表示感谢,不要使用任何特殊格式标签。回复要简短自然(10字以内)。`; + + const chatUrl = member.customApiUrl.replace(/\/+$/, '') + '/chat/completions'; + const headers = { 'Content-Type': 'application/json' }; + if (member.customApiKey) { + headers['Authorization'] = `Bearer ${member.customApiKey}`; + } + + const response = await fetch(chatUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + model: member.customModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 1, + max_tokens: 256 + }) + }); + + if (!response.ok) { + throw new Error('API请求失败'); + } + + const data = await response.json(); + let reply = data.choices?.[0]?.message?.content || ''; + reply = reply.split('|||')[0].trim(); + reply = reply.replace(/^\[.*?\]\s*/, '').trim(); + + return reply || `收到啦,谢谢~`; + } catch (e) { + console.error('[可乐] AI转账感谢消息生成失败:', e); + return `收到转账${tfInfo.amount.toFixed(2)}元,谢谢~`; + } +} + +// ============ UI 渲染 ============ + +/** + * 追加群红包消息到界面 + */ +export function appendGroupRedPacketMessage(role, rpInfo) { + const messagesContainer = document.getElementById('wechat-chat-messages'); + if (!messagesContainer) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`; + + const isDesignated = rpInfo.type === 'designated'; + const isClaimed = rpInfo.status === 'claimed' || (rpInfo.claimedBy && rpInfo.claimedBy.length >= rpInfo.count); + const statusClass = isClaimed ? 'claimed' : ''; + + // 指定成员红包显示特殊样式 + const designatedLabel = isDesignated ? `
给${rpInfo.targetMemberNames?.join('、') || '指定成员'}的红包
` : ''; + + const bubbleHTML = ` +
+
+ +
+
+
${escapeHtml(rpInfo.message || '恭喜发财,大吉大利')}
+ ${designatedLabel} +
${isClaimed ? '已领完' : ''}
+
+
+ + `; + + if (role === 'user') { + messageDiv.innerHTML = ` +
${getUserAvatarHTML()}
+
${bubbleHTML}
+ `; + } else { + const settings = getSettings(); + const contact = settings.contacts?.find(c => c.name === rpInfo.senderName); + const firstChar = rpInfo.senderName?.charAt(0) || '?'; + const avatarContent = contact?.avatar + ? `` + : firstChar; + + messageDiv.innerHTML = ` +
${avatarContent}
+
+
${escapeHtml(rpInfo.senderName)}
+ ${bubbleHTML} +
+ `; + } + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + // 绑定点击事件(查看详情) + const bubble = messageDiv.querySelector('.wechat-group-red-packet-bubble'); + bubble?.addEventListener('click', () => { + showGroupRedPacketDetail(rpInfo); + }); +} + +/** + * 追加群转账消息到界面 + */ +export function appendGroupTransferMessage(role, tfInfo) { + const messagesContainer = document.getElementById('wechat-chat-messages'); + if (!messagesContainer) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`; + + const statusText = tfInfo.status === 'received' ? '已收款' : + tfInfo.status === 'refunded' ? '已退还' : '待收款'; + const statusClass = tfInfo.status || 'pending'; + + const bubbleHTML = ` +
+
+ ¥ +
+
+
¥${tfInfo.amount.toFixed(2)}
+
向${escapeHtml(tfInfo.targetMemberName)}转账
+
${escapeHtml(tfInfo.description) || '转账'}
+
+
${statusText}
+
+ `; + + if (role === 'user') { + messageDiv.innerHTML = ` +
${getUserAvatarHTML()}
+
${bubbleHTML}
+ `; + } else { + const settings = getSettings(); + const contact = settings.contacts?.find(c => c.name === tfInfo.senderName); + const firstChar = tfInfo.senderName?.charAt(0) || '?'; + const avatarContent = contact?.avatar + ? `` + : firstChar; + + messageDiv.innerHTML = ` +
${avatarContent}
+
+
${escapeHtml(tfInfo.senderName)}
+ ${bubbleHTML} +
+ `; + } + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +/** + * 追加群红包领取提示 + */ +function appendGroupRedPacketClaimNotice(claimerName, senderName) { + const messagesContainer = document.getElementById('wechat-chat-messages'); + if (!messagesContainer) return; + + const noticeDiv = document.createElement('div'); + noticeDiv.className = 'wechat-msg-notice'; + noticeDiv.innerHTML = `${escapeHtml(claimerName)}领取了${escapeHtml(senderName)}的红包`; + + messagesContainer.appendChild(noticeDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +/** + * 更新群红包气泡状态 + */ +function updateGroupRedPacketBubbleStatus(rpId) { + const bubble = document.querySelector(`.wechat-group-red-packet-bubble[data-rp-id="${rpId}"]`); + if (!bubble) return; + + // 从聊天记录中找到红包信息 + const settings = getSettings(); + const groupIndex = getCurrentGroupIndex(); + const groupChat = settings.groupChats?.[groupIndex]; + if (!groupChat) return; + + const rpMsg = groupChat.chatHistory?.find(m => m.groupRedPacketInfo?.id === rpId); + const rpInfo = rpMsg?.groupRedPacketInfo; + if (!rpInfo) return; + + const isClaimed = rpInfo.status === 'claimed' || (rpInfo.claimedBy && rpInfo.claimedBy.length >= rpInfo.count); + + if (isClaimed) { + bubble.classList.add('claimed'); + const statusEl = bubble.querySelector('.wechat-group-rp-status'); + if (statusEl) { + statusEl.textContent = '已领完'; + statusEl.classList.remove('hidden'); + } + } +} + +/** + * 更新群转账气泡状态 + */ +function updateGroupTransferBubbleStatus(tfId, status) { + const bubble = document.querySelector(`.wechat-group-transfer-bubble[data-tf-id="${tfId}"]`); + if (!bubble) return; + + bubble.classList.remove('pending', 'received', 'refunded'); + bubble.classList.add(status); + + const statusEl = bubble.querySelector('.wechat-group-tf-status'); + if (statusEl) { + statusEl.textContent = status === 'received' ? '已收款' : + status === 'refunded' ? '已退还' : '待收款'; + } +} + +// ============ 群红包详情页面 ============ + +/** + * 显示群红包详情 + */ +export function showGroupRedPacketDetail(rpInfo) { + const page = document.getElementById('wechat-group-rp-detail-page'); + if (!page) return; + + const settings = getSettings(); + + // 更新详情页内容 + const senderEl = document.getElementById('wechat-group-rp-detail-sender'); + const messageEl = document.getElementById('wechat-group-rp-detail-message'); + const totalEl = document.getElementById('wechat-group-rp-detail-total'); + const countEl = document.getElementById('wechat-group-rp-detail-count'); + const listEl = document.getElementById('wechat-group-rp-detail-list'); + + if (senderEl) { + senderEl.textContent = `${rpInfo.senderName}的红包`; + } + if (messageEl) { + messageEl.textContent = rpInfo.message || '恭喜发财,大吉大利'; + } + if (totalEl) { + totalEl.textContent = '¥' + rpInfo.totalAmount.toFixed(2); + } + if (countEl) { + const claimed = rpInfo.claimedBy?.length || 0; + countEl.textContent = `${claimed}/${rpInfo.count}个红包`; + } + + // 渲染领取列表 + if (listEl) { + if (rpInfo.claimedBy && rpInfo.claimedBy.length > 0) { + // 找出最佳手气(金额最高的) + let maxAmount = 0; + let maxIndex = -1; + rpInfo.claimedBy.forEach((claim, idx) => { + if (claim.amount > maxAmount) { + maxAmount = claim.amount; + maxIndex = idx; + } + }); + + listEl.innerHTML = rpInfo.claimedBy.map((claim, idx) => { + const member = settings.contacts?.find(c => c.id === claim.memberId); + const firstChar = claim.memberName?.charAt(0) || '?'; + const avatarHtml = member?.avatar + ? `` + : firstChar; + + const isBest = rpInfo.type === 'random' && idx === maxIndex && rpInfo.claimedBy.length > 1; + const bestLabel = isBest ? '手气最佳' : ''; + + const claimTime = new Date(claim.claimedAt); + const timeStr = `${claimTime.getHours().toString().padStart(2, '0')}:${claimTime.getMinutes().toString().padStart(2, '0')}`; + + return ` +
+
${avatarHtml}
+
+
${escapeHtml(claim.memberName)} ${bestLabel}
+
${timeStr}
+
+
${claim.amount.toFixed(2)}元
+
+ `; + }).join(''); + } else { + listEl.innerHTML = '
暂无人领取
'; + } + } + + page.classList.remove('hidden'); +} + +/** + * 隐藏群红包详情 + */ +export function hideGroupRedPacketDetail() { + document.getElementById('wechat-group-rp-detail-page')?.classList.add('hidden'); +} + +// ============ 创建页面HTML ============ + +/** + * 创建群红包/转账相关页面(动态注入) + */ +export function createGroupRedPacketPages() { + const phone = document.getElementById('wechat-phone'); + if (!phone) return; + + // 检查是否已存在 + if (document.getElementById('wechat-group-rp-type-page')) return; + + const pagesHTML = ` + + + + + + + + + + + + + + + + + + + + + `; + + phone.insertAdjacentHTML('beforeend', pagesHTML); + + // 绑定事件 + bindGroupRedPacketEvents(); +} + +/** + * 绑定群红包相关事件 + */ +function bindGroupRedPacketEvents() { + // 类型选择页面 + document.getElementById('wechat-group-rp-type-back')?.addEventListener('click', hideGroupRedPacketTypePage); + document.getElementById('wechat-group-rp-type-random')?.addEventListener('click', showGroupRandomRedPacketPage); + document.getElementById('wechat-group-rp-type-designated')?.addEventListener('click', showGroupDesignatedRedPacketPage); + + // 拼手气红包页面 + document.getElementById('wechat-group-random-rp-back')?.addEventListener('click', hideGroupRandomRedPacketPage); + document.getElementById('wechat-group-rp-amount-row')?.addEventListener('click', () => showGroupKeyboard('random-amount')); + document.getElementById('wechat-group-rp-count-row')?.addEventListener('click', () => showGroupKeyboard('random-count')); + document.getElementById('wechat-group-random-rp-submit')?.addEventListener('click', submitGroupRandomRedPacket); + + // 拼手气红包键盘 + document.querySelectorAll('#wechat-group-rp-keyboard .wechat-rp-keyboard-key').forEach(key => { + key.addEventListener('click', () => { + handleGroupKeyboardInput(key.dataset.key); + }); + }); + + // 指定成员红包页面 + document.getElementById('wechat-group-designated-rp-back')?.addEventListener('click', hideGroupDesignatedRedPacketPage); + document.getElementById('wechat-group-designated-amount-row')?.addEventListener('click', () => showGroupKeyboard('designated-amount')); + document.getElementById('wechat-group-designated-rp-submit')?.addEventListener('click', submitGroupDesignatedRedPacket); + + // 指定成员红包键盘 + document.querySelectorAll('#wechat-group-designated-keyboard .wechat-rp-keyboard-key').forEach(key => { + key.addEventListener('click', () => { + handleGroupKeyboardInput(key.dataset.key); + }); + }); + + // 群转账成员选择页面 + document.getElementById('wechat-group-transfer-select-back')?.addEventListener('click', hideGroupTransferSelectPage); + + // 群转账金额输入页面 + document.getElementById('wechat-group-transfer-amount-back')?.addEventListener('click', hideGroupTransferAmountPage); + document.getElementById('wechat-group-transfer-amount-row')?.addEventListener('click', () => showGroupKeyboard('transfer-amount')); + document.getElementById('wechat-group-transfer-submit')?.addEventListener('click', submitGroupTransfer); + + // 群转账键盘 + document.querySelectorAll('#wechat-group-transfer-keyboard .wechat-rp-keyboard-key').forEach(key => { + key.addEventListener('click', () => { + handleGroupKeyboardInput(key.dataset.key); + }); + }); + + // 群红包详情页面 + document.getElementById('wechat-group-rp-detail-back')?.addEventListener('click', hideGroupRedPacketDetail); + + // 密码弹窗 + document.getElementById('wechat-group-password-close')?.addEventListener('click', hideGroupPasswordModal); + document.querySelectorAll('#wechat-group-password-modal .wechat-password-key').forEach(key => { + key.addEventListener('click', () => { + const value = key.dataset.key; + if (value) { + handleGroupPasswordInput(value); + } + }); + }); +} + +// ============ 初始化 ============ + +/** + * 初始化群红包功能 + */ +export function initGroupRedPacket() { + createGroupRedPacketPages(); +} diff --git a/history-logs.js b/history-logs.js index 831a66f..e9813ad 100644 --- a/history-logs.js +++ b/history-logs.js @@ -2,7 +2,7 @@ * 历史回顾和日志功能 */ -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave } from './save-manager.js'; import { getSettings, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js'; import { escapeHtml } from './utils.js'; import { showToast } from './toast.js'; @@ -48,7 +48,7 @@ export function addErrorLog(error, context = '') { settings.errorLogs = settings.errorLogs.slice(0, MAX_LOGS); } - saveSettingsDebounced(); + requestSave(); return logEntry; } @@ -56,7 +56,7 @@ export function addErrorLog(error, context = '') { export function clearErrorLogs() { const settings = getSettings(); settings.errorLogs = []; - saveSettingsDebounced(); + requestSave(); } // 刷新日志列表显示 @@ -185,7 +185,7 @@ export function toggleHistoryItem(index, enabled) { const settings = getSettings(); if (settings.selectedLorebooks?.[index]) { settings.selectedLorebooks[index].enabled = enabled; - saveSettingsDebounced(); + requestSave(); showToast(enabled ? '已启用' : '已禁用'); } } @@ -257,7 +257,7 @@ export function showHistoryDetail(index) { const entryIdx = parseInt(toggle.dataset.entryIndex); if (settings.selectedLorebooks?.[index]?.entries?.[entryIdx]) { settings.selectedLorebooks[index].entries[entryIdx].enabled = toggle.checked; - saveSettingsDebounced(); + requestSave(); } }); }); diff --git a/icons.js b/icons.js new file mode 100644 index 0000000..2ae655c --- /dev/null +++ b/icons.js @@ -0,0 +1,49 @@ +/** + * SVG 图标定义 + * 用于替换 emoji,保持视觉一致性 + */ + +// 红包图标 (替换 🧧) +export const ICON_RED_PACKET = ``; + +// 成功/勾选图标 (替换 ✅) +export const ICON_SUCCESS = ``; + +// 退还箭头图标 (替换 ↩️) +export const ICON_REFUND = ``; + +// 提示/警告图标 (替换 🧊 - 改为感叹号) +export const ICON_INFO = ``; + +// 用户头像图标 (替换 👤) +export const ICON_USER = ``; + +// HTML 版本 (用于直接插入 HTML) +export const ICON_RED_PACKET_HTML = `${ICON_RED_PACKET}`; +export const ICON_SUCCESS_HTML = `${ICON_SUCCESS}`; +export const ICON_REFUND_HTML = `${ICON_REFUND}`; +export const ICON_INFO_HTML = `${ICON_INFO}`; +export const ICON_USER_HTML = `${ICON_USER}`; + +// 大尺寸版本 (用于红包弹窗等需要大图标的地方) +export const ICON_RED_PACKET_LARGE = ``; + +// 获取图标函数 +export function getIcon(type, size = 'normal') { + const icons = { + 'red-packet': size === 'large' ? ICON_RED_PACKET_LARGE : ICON_RED_PACKET, + 'success': ICON_SUCCESS, + 'refund': ICON_REFUND, + 'info': ICON_INFO, + 'user': ICON_USER + }; + return icons[type] || ''; +} + +// 获取 HTML 包装的图标 +export function getIconHTML(type, size = 'normal') { + const icon = getIcon(type, size); + if (!icon) return ''; + const sizeClass = size === 'large' ? 'wechat-icon-large' : ''; + return `${icon}`; +} diff --git a/listen-together.js b/listen-together.js new file mode 100644 index 0000000..2621d3c --- /dev/null +++ b/listen-together.js @@ -0,0 +1,1282 @@ +/** + * 一起听功能模块 + * 与AI角色一起听歌聊天 + */ + +import { getSettings, splitAIMessages } from './config.js'; +import { currentChatIndex } from './chat.js'; +import { requestSave } from './save-manager.js'; +import { refreshChatList } from './ui.js'; +import { searchMusic, playMusic, togglePlay, getCurrentSong, formatDuration } from './music.js'; +import { showToast } from './toast.js'; +import { escapeHtml, sleep } from './utils.js'; + +// ========== SVG 图标 ========== +const LISTEN_ICON = ''; +const BACK_ICON = ''; +const SEARCH_ICON = ''; +const PLAY_ICON = ''; +const PAUSE_ICON = ''; +const PREV_ICON = ''; +const NEXT_ICON = ''; +const CHAT_ICON = ''; +const PLAYLIST_ICON = ''; +const SEND_ICON = ''; +const CLOSE_ICON = ''; +const HEART_ICON = ''; +const LOOP_ICON = ''; + +// ========== 状态管理 ========== +let listenState = { + isActive: false, + isConnected: false, + currentSong: null, + messages: [], + contact: null, + contactIndex: -1, + startTime: null, + isPlaying: false, + connectTimeout: null, + dotsInterval: null, + chatVisible: false, + audioElement: null, + progressInterval: null, + pauseTimeout: null, // 暂停后自动播放下一首的计时器 +}; + +// 导出图标供其他模块使用 +export { LISTEN_ICON }; + +// ========== 页面显示/隐藏 ========== + +/** + * 显示一起听搜索页面 + */ +export function showListenSearchPage() { + const page = document.getElementById('wechat-listen-search-page'); + if (page) { + page.classList.remove('hidden'); + // 聚焦输入框 + setTimeout(() => { + const input = document.getElementById('wechat-listen-search-input'); + if (input) input.focus(); + }, 100); + } +} + +/** + * 隐藏一起听搜索页面 + */ +export function hideListenSearchPage() { + const page = document.getElementById('wechat-listen-search-page'); + if (page) page.classList.add('hidden'); +} + +/** + * 显示等待页面 + */ +function showWaitingPage(song, contact) { + const page = document.getElementById('wechat-listen-waiting-page'); + if (!page) return; + + const settings = getSettings(); + + // 调试日志 + console.log('[一起听等待页面] 数据检查:', { + userAvatar: settings.userAvatar, + contactAvatar: contact.avatar, + songCover: song.cover, + contactName: contact.name + }); + + // 小图显示用户头像 + const avatarEl = document.getElementById('wechat-listen-waiting-avatar'); + if (avatarEl) { + // 先清除旧内容 + avatarEl.innerHTML = ''; + if (settings.userAvatar) { + avatarEl.innerHTML = ``; + } else { + avatarEl.textContent = (settings.userName || 'User').charAt(0); + } + } + + // 大图显示角色头像(带雷达动画的) + const coverEl = document.getElementById('wechat-listen-waiting-cover'); + if (coverEl) { + // 先清除旧值 + coverEl.src = ''; + coverEl.style.background = ''; + + if (contact.avatar) { + coverEl.src = contact.avatar; + } else { + // 如果没有头像,用纯色背景 + coverEl.style.background = '#333'; + } + } + + // 设置角色名 + const nameEl = document.getElementById('wechat-listen-waiting-name'); + if (nameEl) { + nameEl.textContent = contact.name || 'TA'; + } + + page.classList.remove('hidden'); +} + +/** + * 隐藏等待页面 + */ +function hideWaitingPage() { + const page = document.getElementById('wechat-listen-waiting-page'); + if (page) page.classList.add('hidden'); + clearInterval(listenState.dotsInterval); +} + +/** + * 显示一起听主页面 + */ +function showListenTogetherPage() { + const page = document.getElementById('wechat-listen-together-page'); + if (!page) return; + + const settings = getSettings(); + const contact = listenState.contact; + const song = listenState.currentSong; + + // 设置用户头像 + const userAvatarEl = document.getElementById('wechat-listen-user-avatar'); + if (userAvatarEl) { + if (settings.userAvatar) { + userAvatarEl.innerHTML = ``; + } else { + userAvatarEl.textContent = (settings.userName || 'User').charAt(0); + } + } + + // 设置AI头像 + const aiAvatarEl = document.getElementById('wechat-listen-ai-avatar'); + if (aiAvatarEl) { + const firstChar = contact.name ? contact.name.charAt(0) : '?'; + if (contact.avatar) { + aiAvatarEl.innerHTML = ``; + } else { + aiAvatarEl.textContent = firstChar; + } + } + + // 设置歌曲信息 + const coverEl = document.getElementById('wechat-listen-cover'); + const nameEl = document.getElementById('wechat-listen-song-name'); + const artistEl = document.getElementById('wechat-listen-song-artist'); + + if (coverEl && song.cover) coverEl.src = song.cover; + if (nameEl) nameEl.textContent = song.name || '未知歌曲'; + if (artistEl) artistEl.textContent = song.artist || '未知歌手'; + + // 初始化播放按钮状态 + updatePlayButton(); + + page.classList.remove('hidden'); + bindListenEvents(); +} + +/** + * 隐藏一起听主页面 + */ +function hideListenTogetherPage() { + const page = document.getElementById('wechat-listen-together-page'); + if (page) page.classList.add('hidden'); +} + +// ========== 核心逻辑 ========== + +/** + * 开始一起听 + * @param {Object} song - 歌曲信息 + * @param {number} contactIndex - 联系人索引 + */ +export async function startListenTogether(song, contactIndex = currentChatIndex) { + if (listenState.isActive) return; + if (contactIndex < 0) { + showToast('请先选择聊天对象'); + return; + } + + const settings = getSettings(); + const contact = settings.contacts[contactIndex]; + if (!contact) { + showToast('联系人不存在'); + return; + } + + // 初始化状态 + listenState = { + isActive: true, + isConnected: false, + currentSong: song, + messages: [], + contact: contact, + contactIndex: contactIndex, + startTime: null, + isPlaying: false, + connectTimeout: null, + dotsInterval: null, + chatVisible: false, + audioElement: null, + progressInterval: null, + pauseTimeout: null, + }; + + // 隐藏搜索页,显示等待页面 + hideListenSearchPage(); + showWaitingPage(song, contact); + + // 开始等待动画 + startWaitingAnimation(); + + // 2-4秒后AI"加入" + const joinDelay = 2000 + Math.random() * 2000; + listenState.connectTimeout = setTimeout(() => { + if (listenState.isActive && !listenState.isConnected) { + onAIJoined(); + } + }, joinDelay); +} + +/** + * 开始等待动画 + */ +function startWaitingAnimation() { + const dotsEl = document.getElementById('wechat-listen-waiting-dots'); + if (!dotsEl) return; + + let dotCount = 0; + clearInterval(listenState.dotsInterval); + + listenState.dotsInterval = setInterval(() => { + dotCount = (dotCount + 1) % 4; + dotsEl.textContent = '.'.repeat(dotCount || 1); + }, 500); +} + +/** + * AI加入后 + */ +async function onAIJoined() { + listenState.isConnected = true; + listenState.startTime = Date.now(); + + clearInterval(listenState.dotsInterval); + clearTimeout(listenState.connectTimeout); + + // 隐藏等待页面,显示主页面 + hideWaitingPage(); + showListenTogetherPage(); + + // 开始播放音乐 + await playListenSong(); + + // AI主动发送第一条消息 + await triggerAIGreeting(); +} + +/** + * 播放当前歌曲 + */ +async function playListenSong() { + const song = listenState.currentSong; + if (!song) return; + + try { + // 使用music.js的playMusic函数 + await playMusic(song.id, song.platform, song.name, song.artist); + listenState.isPlaying = true; + updatePlayButton(); + startProgressUpdate(); + + // 监听歌曲结束事件 + const audio = document.getElementById('wechat-music-audio'); + if (audio) { + listenState.audioElement = audio; + audio.addEventListener('ended', onSongEnded); + } + } catch (e) { + console.error('[可乐] 一起听播放失败:', e); + showToast('播放失败'); + } +} + +/** + * 歌曲结束时的处理 + */ +async function onSongEnded() { + if (!listenState.isActive) return; + + listenState.isPlaying = false; + updatePlayButton(); + + // 20%几率AI换歌 + if (Math.random() < 0.2) { + await aiSelectSong(); + } +} + +/** + * AI选择歌曲(20%几率触发) + */ +async function aiSelectSong() { + if (!listenState.isConnected || !listenState.contact) return; + + try { + const { callListenTogetherAI } = await import('./ai.js'); + + // 获取最近5条消息 + const recentMessages = listenState.messages.slice(-5); + const messagesContext = recentMessages.map(m => + `${m.role === 'user' ? '用户' : '你'}: ${m.content}` + ).join('\n'); + + // 构建AI选歌的prompt + const prompt = `[这首歌播放完了,请你选择下一首想听的歌,根据你们刚才的聊天氛围和你的喜好来选。 + +最近的对话: +${messagesContext || '(刚开始听歌)'} + +请回复格式: +1. 先说一句为什么想听这首歌(简短自然,1-2句话) +2. 然后用 [换歌:歌名] 格式选择歌曲 + +示例:突然想听点轻快的|||[换歌:晴天]]`; + + showListenTypingIndicator(); + const aiResponse = await callListenTogetherAI(listenState.contact, prompt, recentMessages, listenState.currentSong); + hideListenTypingIndicator(); + + if (aiResponse) { + // 处理回复 + const parts = splitAIMessages(aiResponse); + for (const part of parts) { + const text = filterListenMessage(part); + + // 检查是否包含换歌标签 + const changeSongMatch = text.match(/\[换歌[::]\s*(.+?)\]/); + if (changeSongMatch) { + const songKeyword = changeSongMatch[1].trim(); + // 显示AI的说明文字(去掉换歌标签) + const displayText = text.replace(/\[换歌[::][^\]]*\]/g, '').trim(); + if (displayText) { + addListenMessage('ai', displayText); + } + // 搜索并播放新歌 + await changeSongByKeyword(songKeyword, true); + break; + } else if (text) { + addListenMessage('ai', text); + } + } + } + } catch (err) { + console.error('[可乐] AI选歌失败:', err); + hideListenTypingIndicator(); + } +} + +/** + * 根据关键词换歌 + */ +async function changeSongByKeyword(keyword, isAIChange = false) { + try { + const results = await searchMusic(keyword); + if (results && results.length > 0) { + const newSong = results[0]; + listenState.currentSong = newSong; + + // 更新界面 + const coverEl = document.getElementById('wechat-listen-cover'); + const nameEl = document.getElementById('wechat-listen-song-name'); + const artistEl = document.getElementById('wechat-listen-song-artist'); + + if (coverEl) coverEl.src = newSong.cover || ''; + if (nameEl) nameEl.textContent = newSong.name || '未知歌曲'; + if (artistEl) artistEl.textContent = newSong.artist || '未知歌手'; + + // 播放新歌 + await playListenSong(); + + // 如果不是AI换的歌,通知AI对换歌做出反应 + if (!isAIChange) { + await triggerAISongChangeReaction(newSong); + } + } else { + showToast('未找到歌曲'); + } + } catch (e) { + console.error('[可乐] 换歌失败:', e); + showToast('换歌失败'); + } +} + +/** + * AI对用户换歌的反应 + */ +async function triggerAISongChangeReaction(newSong) { + if (!listenState.isConnected || !listenState.contact) return; + + try { + const { callListenTogetherAI } = await import('./ai.js'); + + const prompt = `[用户换了一首歌,新歌是《${newSong.name}》- ${newSong.artist}。请对换歌做出反应,表达你对这首歌的看法或感受。记得发送2-4条消息,每条换行分隔]`; + + showListenTypingIndicator(); + const aiResponse = await callListenTogetherAI( + listenState.contact, + prompt, + listenState.messages.slice(-5), + newSong + ); + hideListenTypingIndicator(); + + await processAIResponse(aiResponse); + } catch (err) { + hideListenTypingIndicator(); + console.error('[可乐] AI换歌反应失败:', err); + } +} + +/** + * AI主动发送开场消息 + */ +async function triggerAIGreeting() { + if (!listenState.isConnected || !listenState.contact) return; + + showListenTypingIndicator(); + + try { + const { callListenTogetherAI } = await import('./ai.js'); + const song = listenState.currentSong; + + const prompt = `[用户邀请你一起听歌,歌曲是《${song.name}》- ${song.artist},你刚刚加入了一起听。请用你的方式自然地打个招呼,并对这首歌发表一些看法。记得发送2-4条消息,每条换行分隔,像真实聊天一样有层次感]`; + + const aiResponse = await callListenTogetherAI( + listenState.contact, + prompt, + [], + song + ); + + hideListenTypingIndicator(); + await processAIResponse(aiResponse); + + } catch (err) { + hideListenTypingIndicator(); + console.error('[可乐] 一起听AI开场白失败:', err); + } +} + +/** + * 过滤消息 - 只允许纯文字,过滤所有特殊格式 + */ +function filterListenMessage(text) { + if (!text) return ''; + + let reply = text.trim(); + + // 过滤 meme 表情包 + reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim(); + // 过滤 [表情:xxx] + reply = reply.replace(/\[表情[::][^\]]*\]/g, '').trim(); + // 过滤 [照片:xxx] + reply = reply.replace(/\[照片[::][^\]]*\]/g, '').trim(); + // 过滤 [语音:xxx] + reply = reply.replace(/\[语音[::][^\]]*\]/g, '').trim(); + // 过滤 [音乐:xxx](但保留[换歌:xxx]) + reply = reply.replace(/\[(?:分享)?音乐[::][^\]]*\]/g, '').trim(); + // 过滤 [回复:xxx] 引用格式 + reply = reply.replace(/\[回复[::][^\]]*\]/g, '').trim(); + // 过滤中文小括号内容(动作/语气描述) + reply = reply.replace(/([^)]*)/g, '').trim(); + // 过滤英文小括号内容 + reply = reply.replace(/\([^)]*\)/g, '').trim(); + + return reply; +} + +/** + * 处理AI回复 - 纯文字消息 + */ +async function processAIResponse(aiResponse) { + if (!aiResponse) return; + + const parts = splitAIMessages(aiResponse); + + for (const part of parts) { + if (!listenState.isConnected) break; + + let reply = filterListenMessage(part); + if (!reply) continue; + + // 直接发送纯文字消息 + showListenTypingIndicator(); + await sleep(400 + Math.random() * 600); + hideListenTypingIndicator(); + if (listenState.isConnected) { + addListenMessage('ai', reply); + } + } +} + +/** + * 用户发送消息 + */ +async function sendListenMessage() { + const input = document.getElementById('wechat-listen-input-text'); + if (!input) return; + + const message = input.value.trim(); + if (!message || !listenState.isConnected) return; + + input.value = ''; + + // 显示用户消息 + addListenMessage('user', message); + + // 显示typing + showListenTypingIndicator(); + + try { + const { callListenTogetherAI } = await import('./ai.js'); + const song = listenState.currentSong; + + const aiResponse = await callListenTogetherAI( + listenState.contact, + message, + listenState.messages.slice(0, -1), + song + ); + + hideListenTypingIndicator(); + await processAIResponse(aiResponse); + + } catch (err) { + hideListenTypingIndicator(); + console.error('[可乐] 一起听消息回复失败:', err); + } +} + +// ========== UI 更新 ========== + +/** + * 显示typing指示器 + */ +function showListenTypingIndicator() { + const messagesEl = document.getElementById('wechat-listen-messages'); + if (!messagesEl) return; + + messagesEl.classList.remove('hidden'); + hideListenTypingIndicator(); + + const typingDiv = document.createElement('div'); + typingDiv.className = 'wechat-listen-msg ai'; + typingDiv.id = 'wechat-listen-typing'; + typingDiv.innerHTML = ` +
+ + + +
+ `; + + messagesEl.appendChild(typingDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; +} + +/** + * 隐藏typing指示器 + */ +function hideListenTypingIndicator() { + const typingEl = document.getElementById('wechat-listen-typing'); + if (typingEl) typingEl.remove(); +} + +/** + * 添加聊天消息 + */ +function addListenMessage(role, content) { + const messagesEl = document.getElementById('wechat-listen-messages'); + if (!messagesEl) return; + + messagesEl.classList.remove('hidden'); + + // 添加到状态 + listenState.messages.push({ role, content, timestamp: Date.now() }); + + // 创建消息元素 + const msgDiv = document.createElement('div'); + msgDiv.className = `wechat-listen-msg ${role} fade-in`; + msgDiv.textContent = content; + + messagesEl.appendChild(msgDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; + + // 限制显示的消息数量 + const msgs = messagesEl.querySelectorAll('.wechat-listen-msg:not(#wechat-listen-typing)'); + if (msgs.length > 15) { + msgs[0].remove(); + } +} + +/** + * 更新播放按钮状态 + */ +function updatePlayButton() { + const playBtn = document.getElementById('wechat-listen-play-btn'); + if (playBtn) { + playBtn.innerHTML = listenState.isPlaying ? PAUSE_ICON : PLAY_ICON; + } + + // 更新唱片旋转 + const disc = document.getElementById('wechat-listen-disc'); + if (disc) { + if (listenState.isPlaying) { + disc.classList.add('rotating'); + disc.classList.remove('paused'); + } else { + disc.classList.add('paused'); + } + } +} + +/** + * 处理播放/暂停点击 + * 暂停3秒后自动播放下一首 + */ +function handlePlayPauseClick() { + togglePlay(); + listenState.isPlaying = !listenState.isPlaying; + updatePlayButton(); + + // 清除之前的暂停计时器 + if (listenState.pauseTimeout) { + clearTimeout(listenState.pauseTimeout); + listenState.pauseTimeout = null; + } + + // 如果暂停了,启动3秒后自动播放下一首的计时器 + if (!listenState.isPlaying && listenState.isActive) { + listenState.pauseTimeout = setTimeout(async () => { + if (!listenState.isPlaying && listenState.isActive) { + await autoPlayNextSong(); + } + }, 3000); + } +} + +/** + * 自动播放下一首歌(暂停3秒后触发) + */ +async function autoPlayNextSong() { + if (!listenState.isActive || !listenState.currentSong) return; + + try { + // 搜索相似歌曲或随机歌曲 + const currentSong = listenState.currentSong; + const keyword = currentSong.artist || currentSong.name; + + const results = await searchMusic(keyword); + if (results && results.length > 1) { + // 找一首不同的歌 + const newSong = results.find(s => s.id !== currentSong.id) || results[1]; + + listenState.currentSong = newSong; + + // 更新界面 + const coverEl = document.getElementById('wechat-listen-cover'); + const nameEl = document.getElementById('wechat-listen-song-name'); + const artistEl = document.getElementById('wechat-listen-song-artist'); + + if (coverEl) coverEl.src = newSong.cover || ''; + if (nameEl) nameEl.textContent = newSong.name || '未知歌曲'; + if (artistEl) artistEl.textContent = newSong.artist || '未知歌手'; + + // 播放新歌 + await playListenSong(); + + // AI 对自动换歌做出反应 + await triggerAIAutoNextReaction(newSong); + } + } catch (e) { + console.error('[可乐] 自动播放下一首失败:', e); + } +} + +/** + * AI 对自动换歌的反应 + */ +async function triggerAIAutoNextReaction(newSong) { + if (!listenState.isConnected || !listenState.contact) return; + + try { + const { callListenTogetherAI } = await import('./ai.js'); + + const prompt = `[歌曲自动切换到了《${newSong.name}》- ${newSong.artist},请对新歌做出反应,发送2-3条消息,每条换行分隔]`; + + showListenTypingIndicator(); + const aiResponse = await callListenTogetherAI( + listenState.contact, + prompt, + listenState.messages.slice(-3), + newSong + ); + hideListenTypingIndicator(); + + await processAIResponse(aiResponse); + } catch (err) { + hideListenTypingIndicator(); + console.error('[可乐] AI自动换歌反应失败:', err); + } +} + +/** + * 开始进度条更新 + */ +function startProgressUpdate() { + clearInterval(listenState.progressInterval); + + listenState.progressInterval = setInterval(() => { + const audio = listenState.audioElement || document.getElementById('wechat-music-audio'); + if (!audio) return; + + const currentTime = audio.currentTime || 0; + const duration = audio.duration || 0; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + const currentEl = document.getElementById('wechat-listen-current-time'); + const durationEl = document.getElementById('wechat-listen-duration'); + const fillEl = document.getElementById('wechat-listen-progress-fill'); + const sliderEl = document.getElementById('wechat-listen-slider'); + + if (currentEl) currentEl.textContent = formatDuration(currentTime); + if (durationEl) durationEl.textContent = formatDuration(duration); + if (fillEl) fillEl.style.width = progress + '%'; + if (sliderEl) sliderEl.value = progress; + }, 500); +} + +// ========== 事件绑定 ========== + +let listenEventsBound = false; +let searchEventsBound = false; + +/** + * 绑定搜索页面事件 + */ +export function bindListenSearchEvents() { + if (searchEventsBound) return; + searchEventsBound = true; + + // 返回按钮 + document.getElementById('wechat-listen-search-back')?.addEventListener('click', () => { + hideListenSearchPage(); + }); + + // 搜索输入 + const searchInput = document.getElementById('wechat-listen-search-input'); + let searchTimeout = null; + + searchInput?.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + doListenSearch(e.target.value.trim()); + }, 500); + }); + + searchInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + clearTimeout(searchTimeout); + doListenSearch(e.target.value.trim()); + } + }); + + // 搜索结果点击 + document.getElementById('wechat-listen-search-results')?.addEventListener('click', (e) => { + const item = e.target.closest('.wechat-listen-search-item'); + if (!item) return; + + const song = { + id: item.dataset.id, + platform: item.dataset.platform, + name: item.dataset.name, + artist: item.dataset.artist, + cover: item.dataset.cover, + }; + + startListenTogether(song); + }); +} + +/** + * 执行搜索 + */ +async function doListenSearch(keyword) { + const resultsEl = document.getElementById('wechat-listen-search-results'); + if (!resultsEl) return; + + if (!keyword) { + resultsEl.innerHTML = '
输入关键词搜索歌曲
'; + return; + } + + resultsEl.innerHTML = '
搜索中...
'; + + try { + const results = await searchMusic(keyword); + + if (!results || results.length === 0) { + resultsEl.innerHTML = '
未找到结果
'; + return; + } + + let html = ''; + for (const song of results) { + html += ` +
+
+ +
+
+
${escapeHtml(song.name)}
+
${escapeHtml(song.artist)} - ${escapeHtml(song.platform)}
+
+
+ `; + } + resultsEl.innerHTML = html; + + } catch (err) { + console.error('[可乐] 一起听搜索失败:', err); + resultsEl.innerHTML = '
搜索失败
'; + } +} + +/** + * 绑定一起听主页面事件 + */ +function bindListenEvents() { + if (listenEventsBound) return; + listenEventsBound = true; + + // 发送消息 + document.getElementById('wechat-listen-send-btn')?.addEventListener('click', sendListenMessage); + + // 输入框回车发送 + document.getElementById('wechat-listen-input-text')?.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendListenMessage(); + } + }); + + // 播放/暂停 + document.getElementById('wechat-listen-play-btn')?.addEventListener('click', handlePlayPauseClick); + + // 星星按钮 - 打开颜色选择器 + document.getElementById('wechat-listen-color-btn')?.addEventListener('click', toggleColorPicker); + + // 颜色选择器选项点击 + document.getElementById('wechat-listen-color-picker')?.addEventListener('click', handleColorOptionClick); + + // 结束按钮 + document.getElementById('wechat-listen-end-btn')?.addEventListener('click', exitListenTogether); + + // 换歌面板关闭 + document.getElementById('wechat-listen-change-close')?.addEventListener('click', hideChangeSongPanel); + + // 换歌搜索 + const changeInput = document.getElementById('wechat-listen-change-input'); + let changeSearchTimeout = null; + + changeInput?.addEventListener('input', (e) => { + clearTimeout(changeSearchTimeout); + changeSearchTimeout = setTimeout(() => { + doChangeSongSearch(e.target.value.trim()); + }, 500); + }); + + // 换歌搜索结果点击 + document.getElementById('wechat-listen-change-results')?.addEventListener('click', (e) => { + const item = e.target.closest('.wechat-listen-change-item'); + if (!item) return; + + const song = { + id: item.dataset.id, + platform: item.dataset.platform, + name: item.dataset.name, + artist: item.dataset.artist, + cover: item.dataset.cover, + }; + + changeSong(song); + hideChangeSongPanel(); + }); + + // 取消一起听 + document.getElementById('wechat-listen-cancel')?.addEventListener('click', cancelListenTogether); + + // 返回按钮(主页面的返回) + document.getElementById('wechat-listen-back-btn')?.addEventListener('click', exitListenTogether); + + // 进度条拖动 + const slider = document.getElementById('wechat-listen-slider'); + slider?.addEventListener('change', (e) => { + const audio = listenState.audioElement || document.getElementById('wechat-music-audio'); + if (audio && audio.duration) { + audio.currentTime = (e.target.value / 100) * audio.duration; + } + }); +} + +/** + * 背景颜色映射 + */ +const LISTEN_BACKGROUNDS = { + 'starry': 'linear-gradient(135deg, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #533483 100%)', + 'orange': 'linear-gradient(135deg, #f97316 0%, #ea580c 50%, #c2410c 100%)', + 'pink': 'linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)', + 'white': '#fff' +}; +let currentBg = 'starry'; + +/** + * 切换颜色选择器显示 + */ +function toggleColorPicker() { + const picker = document.getElementById('wechat-listen-color-picker'); + if (picker) { + picker.classList.toggle('hidden'); + } +} + +/** + * 隐藏颜色选择器 + */ +function hideColorPicker() { + const picker = document.getElementById('wechat-listen-color-picker'); + if (picker) { + picker.classList.add('hidden'); + } +} + +/** + * 处理颜色选项点击 + */ +function handleColorOptionClick(e) { + const option = e.target.closest('.wechat-listen-color-option'); + if (!option) return; + + const bgType = option.dataset.bg; + if (!bgType || !LISTEN_BACKGROUNDS[bgType]) return; + + // 更新页面背景 + const page = document.getElementById('wechat-listen-together-page'); + if (page) { + page.style.background = LISTEN_BACKGROUNDS[bgType]; + + // 如果是白色背景,需要调整文字颜色 + if (bgType === 'white') { + page.classList.add('light-bg'); + } else { + page.classList.remove('light-bg'); + } + } + + // 更新选中状态 + document.querySelectorAll('.wechat-listen-color-option').forEach(opt => { + opt.classList.remove('active'); + }); + option.classList.add('active'); + + currentBg = bgType; + hideColorPicker(); +} + +/** + * 显示换歌面板 + */ +function showChangeSongPanel() { + const panel = document.getElementById('wechat-listen-change-panel'); + if (panel) { + panel.classList.remove('hidden'); + document.getElementById('wechat-listen-change-input')?.focus(); + } +} + +/** + * 隐藏换歌面板 + */ +function hideChangeSongPanel() { + const panel = document.getElementById('wechat-listen-change-panel'); + if (panel) panel.classList.add('hidden'); +} + +/** + * 换歌搜索 + */ +async function doChangeSongSearch(keyword) { + const resultsEl = document.getElementById('wechat-listen-change-results'); + if (!resultsEl) return; + + if (!keyword) { + resultsEl.innerHTML = ''; + return; + } + + resultsEl.innerHTML = '
搜索中...
'; + + try { + const results = await searchMusic(keyword); + + if (!results || results.length === 0) { + resultsEl.innerHTML = '
未找到结果
'; + return; + } + + let html = ''; + for (const song of results.slice(0, 10)) { + html += ` +
+
${escapeHtml(song.name)}
+
${escapeHtml(song.artist)}
+
+ `; + } + resultsEl.innerHTML = html; + + } catch (err) { + resultsEl.innerHTML = '
搜索失败
'; + } +} + +/** + * 换歌 + */ +async function changeSong(song) { + listenState.currentSong = song; + + // 更新界面 + const coverEl = document.getElementById('wechat-listen-cover'); + const nameEl = document.getElementById('wechat-listen-song-name'); + const artistEl = document.getElementById('wechat-listen-song-artist'); + + if (coverEl) coverEl.src = song.cover || ''; + if (nameEl) nameEl.textContent = song.name || '未知歌曲'; + if (artistEl) artistEl.textContent = song.artist || '未知歌手'; + + // 播放新歌 + await playListenSong(); + + // 通知AI对换歌做出反应 + await triggerAISongChangeReaction(song); +} + +/** + * 取消一起听(等待页面) + */ +function cancelListenTogether() { + clearInterval(listenState.dotsInterval); + clearTimeout(listenState.connectTimeout); + clearInterval(listenState.progressInterval); + clearTimeout(listenState.pauseTimeout); + + hideWaitingPage(); + + listenState = { + isActive: false, + isConnected: false, + currentSong: null, + messages: [], + contact: null, + contactIndex: -1, + startTime: null, + isPlaying: false, + connectTimeout: null, + dotsInterval: null, + chatVisible: false, + audioElement: null, + progressInterval: null, + pauseTimeout: null, + }; +} + +/** + * 退出一起听 + */ +export async function exitListenTogether() { + if (!listenState.isActive) return; + + clearInterval(listenState.dotsInterval); + clearTimeout(listenState.connectTimeout); + clearInterval(listenState.progressInterval); + clearTimeout(listenState.pauseTimeout); + + // 移除音频结束监听 + if (listenState.audioElement) { + listenState.audioElement.removeEventListener('ended', onSongEnded); + } + + // 保存一起听记录(不显示在聊天里) + const contact = listenState.contact; + const song = listenState.currentSong; + const messages = [...listenState.messages]; + + if (contact && messages.length > 0) { + saveListenHistory(); + } + + // 隐藏所有页面 + hideWaitingPage(); + hideListenTogetherPage(); + hideListenSearchPage(); + hideChangeSongPanel(); + + // 重置状态 + listenState = { + isActive: false, + isConnected: false, + currentSong: null, + messages: [], + contact: null, + contactIndex: -1, + startTime: null, + isPlaying: false, + connectTimeout: null, + dotsInterval: null, + chatVisible: false, + audioElement: null, + progressInterval: null, + pauseTimeout: null, + }; + + // AI 结束一起听后的回复 + if (contact && song) { + await triggerAIListenEndReply(contact, song, messages); + } +} + +/** + * AI 结束一起听后的回复 + */ +async function triggerAIListenEndReply(contact, song, messages) { + try { + const { callAI } = await import('./ai.js'); + const { appendMessage, showTypingIndicator, hideTypingIndicator } = await import('./chat.js'); + + // 显示打字指示器 + showTypingIndicator(contact); + + // 构建提示 + const recentMsgs = messages.slice(-5).map(m => + `${m.role === 'user' ? '用户' : '你'}: ${m.content}` + ).join('\n'); + + const prompt = `[刚才和用户一起听了《${song.name}》- ${song.artist},一起听已经结束了。请根据刚才的聊天氛围,说一句告别或感想,简短自然,不要使用任何特殊格式标签。 + +刚才的聊天: +${recentMsgs || '(没有聊天)'}]`; + + const aiResponse = await callAI(contact, prompt); + hideTypingIndicator(); + + if (aiResponse && aiResponse.trim()) { + let reply = aiResponse.split('|||')[0].trim(); + reply = reply.replace(/^\[.*?\]\s*/, ''); + reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim(); + + if (reply) { + const settings = getSettings(); + const now = new Date(); + const timeStr = now.toLocaleString('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }).replace(/\//g, '-'); + + // 添加到聊天历史 + if (!contact.chatHistory) contact.chatHistory = []; + contact.chatHistory.push({ + role: 'assistant', + content: reply, + time: timeStr, + timestamp: Date.now() + }); + + appendMessage('assistant', reply, contact); + contact.lastMessage = reply; + requestSave(); + refreshChatList(); + } + } + } catch (err) { + console.error('[可乐] AI一起听结束回复失败:', err); + // 隐藏typing + import('./chat.js').then(m => m.hideTypingIndicator()); + } +} + +/** + * 保存一起听历史记录(不显示在聊天中) + */ +function saveListenHistory() { + const settings = getSettings(); + const contact = listenState.contact; + + if (!contact) return; + + 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 durationStr = '00:00'; + if (listenState.startTime) { + const elapsed = Math.floor((Date.now() - listenState.startTime) / 1000); + const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0'); + const seconds = (elapsed % 60).toString().padStart(2, '0'); + durationStr = `${minutes}:${seconds}`; + } + + // 保存到联系人的一起听历史(仅保存记录,不显示在聊天中) + if (!Array.isArray(contact.listenHistory)) { + contact.listenHistory = []; + } + + contact.listenHistory.push({ + song: listenState.currentSong, + duration: durationStr, + time: timeStr, + timestamp: Date.now(), + messages: listenState.messages.map(m => ({ role: m.role, content: m.content })) + }); + + // 限制历史记录数量 + if (contact.listenHistory.length > 50) { + contact.listenHistory = contact.listenHistory.slice(-50); + } + + requestSave(); +} + +/** + * 初始化一起听功能 + */ +export function initListenTogether() { + bindListenSearchEvents(); +} \ No newline at end of file diff --git a/main.js b/main.js index 688c0f3..9c205a2 100644 --- a/main.js +++ b/main.js @@ -4,12 +4,13 @@ console.log('[可乐] main.js 开始加载...'); -import { saveSettingsDebounced } from '../../../../script.js'; +import { requestSave, setupUnloadSave } from './save-manager.js'; import { loadSettings, getSettings, MEME_PROMPT_TEMPLATE } from './config.js'; import { generatePhoneHTML } from './phone-html.js'; import { showPage, refreshChatList, updateMePageInfo, getUserPersonaFromST, updateTabBadge } from './ui.js'; import { showToast } from './toast.js'; +import { ICON_SUCCESS, ICON_INFO } from './icons.js'; import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex } from './contacts.js'; import { openChatByContactId, setCurrentChatIndex, sendMessage, showRecalledMessages, currentChatIndex, openChat } from './chat.js'; @@ -30,6 +31,10 @@ import { getCurrentTime } from './utils.js'; import { refreshHistoryList, refreshLogsList, clearErrorLogs, initErrorCapture, addErrorLog } from './history-logs.js'; import { initChatBackground } from './chat-background.js'; import { initMoments, openMomentsPage, clearContactMoments } from './moments.js'; +import { initRedPacketEvents } from './red-packet.js'; +import { initTransferEvents } from './transfer.js'; +import { initGroupRedPacket } from './group-red-packet.js'; +import { initCropper } from './cropper.js'; function normalizeModelListForSelect(models) { return (models || []).map(m => { @@ -76,7 +81,7 @@ async function refreshModelSelect() { const apiKey = document.getElementById('wechat-api-key')?.value?.trim() || settings.apiKey || ''; if (!apiUrl) { - showToast('请先填写 API 地址', '🧊'); + showToast('请先填写 API 地址', 'info'); return; } @@ -94,7 +99,7 @@ async function refreshModelSelect() { modelIds.map(id => ``).join(''); settings.modelList = modelIds; - saveSettingsDebounced(); + requestSave(); showToast(`获取到 ${modelIds.length} 个模型`); } catch (err) { console.error('[可乐] 获取模型列表失败:', err); @@ -248,7 +253,7 @@ function bindEvents() { if (groupChat) { groupChat.chatHistory = []; groupChat.lastMessage = ''; - saveSettingsDebounced(); + requestSave(); openGroupChat(groupIndex); // 刷新群聊界面 showToast('群聊记录已清空'); } @@ -264,7 +269,7 @@ function bindEvents() { if (contact) { contact.chatHistory = []; contact.lastMessage = ''; - saveSettingsDebounced(); + requestSave(); openChat(currentChatIndex); // 刷新聊天界面 showToast('聊天记录已清空'); } @@ -369,7 +374,7 @@ function bindEvents() { if (contentDiv) { contentDiv.classList.toggle('hidden', !settings.autoInjectPrompt); } - saveSettingsDebounced(); + requestSave(); if (settings.autoInjectPrompt) injectAuthorNote(); }); @@ -377,7 +382,7 @@ function bindEvents() { document.getElementById('wechat-save-author-note')?.addEventListener('click', () => { const settings = getSettings(); settings.authorNoteCustom = document.getElementById('wechat-author-note-content')?.value || ''; - saveSettingsDebounced(); + requestSave(); showToast('作者注释模板已保存'); }); @@ -391,7 +396,7 @@ function bindEvents() { if (contentDiv) { contentDiv.classList.toggle('hidden', !settings.hakimiBreakLimit); } - saveSettingsDebounced(); + requestSave(); showToast(settings.hakimiBreakLimit ? '哈基米破限已开启' : '哈基米破限已关闭'); }); @@ -399,7 +404,7 @@ function bindEvents() { document.getElementById('wechat-save-hakimi')?.addEventListener('click', () => { const settings = getSettings(); settings.hakimiCustomPrompt = document.getElementById('wechat-hakimi-prompt')?.value || ''; - saveSettingsDebounced(); + requestSave(); showToast('破限提示词已保存'); }); @@ -415,7 +420,7 @@ function bindEvents() { settings.memeStickersEnabled = !settings.memeStickersEnabled; const toggle = document.getElementById('wechat-meme-stickers-toggle'); toggle?.classList.toggle('on', settings.memeStickersEnabled); - saveSettingsDebounced(); + requestSave(); showToast(settings.memeStickersEnabled ? 'Meme表情包已启用' : 'Meme表情包已禁用'); }); @@ -532,7 +537,7 @@ function bindEvents() { const fetchBtn = document.getElementById('wechat-contact-fetch-model'); if (!apiUrl) { - showToast('请先填写API地址', '🧊'); + showToast('请先填写API地址', 'info'); return; } @@ -547,7 +552,7 @@ function bindEvents() { modelList.innerHTML = models.map(m => `