Files
Cola/group-chat.js
2025-12-23 15:20:30 +08:00

2926 lines
109 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 群聊功能
*/
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';
import { escapeHtml, sleep, formatMessageTime, calculateVoiceDuration, bindImageLoadFallback } from './utils.js';
import { getUserAvatarHTML, refreshChatList, getUserPersonaFromST } from './ui.js';
import { getSTChatContext, HAKIMI_HEADER } from './ai.js';
import { playMusic as kugouPlayMusic } from './music.js';
import { showMessageMenu } from './message-menu.js';
// 当前群聊的索引
export let currentGroupChatIndex = -1;
// 替换消息中的占位符
const GROUP_CHAT_HISTORY_LIMIT = 300;
const GROUP_CHAT_SUMMARY_REMINDER_THRESHOLD = 300; // 达到此条数时提醒总结
const GROUP_CHAT_PERSONA_PREAMBLE_ENABLED = true;
const GROUP_CHAT_PERSONA_PREAMBLE_MAX_CHARS = 60000; // 用户设定最大字符数模型支持128K上下文
const GROUP_CHAT_DEBUG = false;
// 群聊上限:最多 3 个独立 AI + 1 个用户(合计 4
const GROUP_CHAT_MAX_AI_MEMBERS = 3;
// 检查群聊记录是否需要总结提醒
function checkGroupSummaryReminder(groupChat) {
if (!groupChat || !groupChat.chatHistory) return;
// 查找最后一个总结标记的位置
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;
}
}
// 解析用户表情包 token -> URL
function resolveUserStickerUrl(token, settings) {
if (settings.userStickersEnabled === false) return null;
const stickers = getUserStickers(settings);
if (stickers.length === 0) return null;
const raw = (token || '').toString().trim();
if (!raw) return null;
// 序号匹配
if (/^\d+$/.test(raw)) {
const index = parseInt(raw, 10) - 1;
return stickers[index]?.url || null;
}
// 名称匹配
const key = raw.toLowerCase();
const byName = stickers.find(s => (s?.name || '').toLowerCase() === key);
if (byName?.url) return byName.url;
// 模糊匹配
const fuzzy = stickers.find(s => {
const name = (s?.name || '').toLowerCase();
return name && (name.includes(key) || key.includes(name));
});
return fuzzy?.url || null;
}
function isEnabledFlag(value) {
return value !== false && value !== 'false';
}
function getEnabledUserPersonas(settings) {
const personas = Array.isArray(settings?.userPersonas) ? settings.userPersonas : [];
// 如果用户在插件里显式维护了 userPersonas则严格遵循其 enabled 开关
if (personas.length > 0) {
return personas.filter(p => p && isEnabledFlag(p.enabled));
}
const stPersona = getUserPersonaFromST();
const content = stPersona?.description?.trim();
if (!content) return [];
return [{
name: (stPersona?.name || '').trim() || '用户设定',
content,
enabled: true,
addedTime: '',
source: 'sillytavern',
}];
}
function buildUserPersonaBlock(settings) {
const enabledPersonas = getEnabledUserPersonas(settings);
if (enabledPersonas.length === 0) return '';
let text = `【用户设定】\n`;
enabledPersonas.forEach(persona => {
const name = (persona?.name || '').trim();
const content = (persona?.content || '').trim();
if (name) text += `[${name}]\n`;
if (content) text += `${replacePromptPlaceholders(content)}\n`;
});
return text.trim();
}
function isDisabledTrueFlag(value) {
return value === true || value === 'true';
}
function isLorebookEnabled(lorebook) {
if (!lorebook) return false;
if (!isEnabledFlag(lorebook.enabled)) return false;
if (isDisabledTrueFlag(lorebook.disable)) return false;
return true;
}
function isLorebookEntryEnabled(entry) {
if (!entry) return false;
if (!isEnabledFlag(entry.enabled)) return false;
if (isDisabledTrueFlag(entry.disable)) return false;
return true;
}
function findCharacterLorebookForMember(member, settings) {
const selectedLorebooks = Array.isArray(settings?.selectedLorebooks) ? settings.selectedLorebooks : [];
const rawData = member?.rawData || {};
const charData = rawData.data || rawData;
const charName = (charData?.name || member?.name || '').trim();
return selectedLorebooks.find(lb => {
if (!lb?.fromCharacter) return false;
if (member?.id && lb.characterId && lb.characterId === member.id) return true;
if (charName && lb.characterName && lb.characterName === charName) return true;
if (charName && lb.name && lb.name === charName) return true;
return false;
}) || null;
}
function buildMemberCharacterBookBlock(member, settings) {
const rawData = member?.rawData || {};
const charData = rawData.data || rawData;
const charName = (charData?.name || member?.name || '').trim();
const contents = [];
// 优先:使用 selectedLorebooks 里同步的“角色世界书”,以便严格遵循启用/关闭开关
const characterLorebook = findCharacterLorebookForMember(member, settings);
if (characterLorebook) {
// 若该角色世界书被关闭,则完全不注入(避免“关了还生效”)
if (!isLorebookEnabled(characterLorebook)) return '';
(characterLorebook.entries || []).forEach(entry => {
if (!entry?.content) return;
if (!isLorebookEntryEnabled(entry)) return;
contents.push(entry.content);
});
}
// 回退:如果没找到同步世界书/或条目为空,则尝试从 rawData.character_book 读取(同样遵循 entry 开关)
if (contents.length === 0) {
const bookEntries = Array.isArray(charData?.character_book?.entries) ? charData.character_book.entries : [];
bookEntries.forEach(entry => {
if (!entry?.content) return;
if (!isLorebookEntryEnabled(entry)) return;
contents.push(entry.content);
});
}
const uniqueContents = Array.from(new Set(contents.map(c => (c || '').trim()).filter(Boolean)));
if (uniqueContents.length === 0) return '';
const title = charName || member?.name || '角色';
let text = `${title}专属世界书】\n`;
uniqueContents.forEach(content => {
text += `- ${replacePromptPlaceholders(content)}\n`;
});
return text.trim();
}
function buildGlobalLorebookBlock(settings) {
const selectedLorebooks = Array.isArray(settings?.selectedLorebooks) ? settings.selectedLorebooks : [];
const contents = [];
selectedLorebooks.forEach(lb => {
if (!lb || lb.fromCharacter) return;
if (!isLorebookEnabled(lb)) return;
(lb.entries || []).forEach(entry => {
if (!entry?.content) return;
if (!isLorebookEntryEnabled(entry)) return;
contents.push(entry.content);
});
});
const uniqueContents = Array.from(new Set(contents.map(c => (c || '').trim()).filter(Boolean)));
if (uniqueContents.length === 0) return '';
let text = `【共享世界观】\n`;
uniqueContents.forEach(content => {
text += `- ${replacePromptPlaceholders(content)}\n`;
});
return text.trim();
}
function buildUserPersonaPreamble(settings, member = null) {
const personaBlock = buildUserPersonaBlock(settings);
const characterBookBlock = member ? buildMemberCharacterBookBlock(member, settings) : '';
const globalLorebookBlock = buildGlobalLorebookBlock(settings);
if (!personaBlock && !characterBookBlock && !globalLorebookBlock) return '';
const blocks = [];
if (personaBlock) blocks.push(personaBlock);
if (characterBookBlock) blocks.push(characterBookBlock);
if (globalLorebookBlock) blocks.push(globalLorebookBlock);
let preamble = `(以下为长期设定/背景信息,不是本轮发言;请在回复时始终遵守)\n${blocks.join('\n\n')}`;
if (preamble.length > GROUP_CHAT_PERSONA_PREAMBLE_MAX_CHARS) {
preamble = preamble.slice(0, GROUP_CHAT_PERSONA_PREAMBLE_MAX_CHARS).trimEnd() + '\n用户设定过长已截断';
}
return preamble;
}
export function enforceGroupChatMemberLimit(groupChat, { toast = false } = {}) {
const memberIds = Array.isArray(groupChat?.memberIds) ? groupChat.memberIds.filter(Boolean) : [];
if (memberIds.length <= GROUP_CHAT_MAX_AI_MEMBERS) {
return { memberIds, wasTrimmed: false, originalCount: memberIds.length };
}
const trimmed = memberIds.slice(0, GROUP_CHAT_MAX_AI_MEMBERS);
groupChat.memberIds = trimmed;
requestSave();
if (toast) {
showToast(`群聊最多 ${GROUP_CHAT_MAX_AI_MEMBERS} 个成员(+你=4已自动裁剪`, '⚠️');
}
return { memberIds: trimmed, wasTrimmed: true, originalCount: memberIds.length };
}
function getGroupChatHistoryForApi(chatHistory, maxMessages = GROUP_CHAT_HISTORY_LIMIT) {
const history = Array.isArray(chatHistory) ? chatHistory : [];
const filtered = history.filter(msg => {
if (!msg) return false;
if (msg.isMarker) return false;
const content = msg.content || '';
if (typeof content === 'string' && content.startsWith(SUMMARY_MARKER_PREFIX)) return false;
return msg.role === 'user' || msg.role === 'assistant';
});
return filtered.slice(-maxMessages);
}
function replaceMessagePlaceholders(content) {
if (!content) return content;
const context = getContext();
const userName = context?.name1 || 'User';
// 替换 {{user}} 占位符(不区分大小写)
return content.replace(/\{\{user\}\}/gi, userName);
}
// 替换用户设定和世界书中的占位符(包括 {{user}}
function replacePromptPlaceholders(content) {
if (!content) return content;
const context = getContext();
const settings = getSettings();
let result = content;
// 替换 {{user}} - 优先使用插件内的用户设定名称,否则使用酒馆的 name1
const enabledPersonas = getEnabledUserPersonas(settings);
const personaName = (enabledPersonas.find(p => (p?.name || '').trim())?.name || '').trim();
// 如果有启用的用户设定且有名称,使用第一个的名称;否则用酒馆的 name1
const userName = personaName || (context?.name1 || 'User');
result = result.replace(/\{\{user\}\}/gi, userName);
// 替换 {{char}} - 当前角色名(在调用处处理)
// 这里只处理通用占位符
return result;
}
// 设置当前群聊索引
export function setCurrentGroupChatIndex(index) {
currentGroupChatIndex = index;
}
// 显示群聊创建弹窗
export function showGroupCreateModal() {
const settings = getSettings();
const contacts = settings.contacts || [];
if (contacts.length < 2) {
showToast('至少需要2个联系人才能创建群聊', '⚠️');
return;
}
// 填充联系人列表
const listContainer = document.getElementById('wechat-group-contacts-list');
if (listContainer) {
listContainer.innerHTML = contacts.map((contact, index) => {
const firstChar = contact.name ? contact.name.charAt(0) : '?';
const avatarHtml = contact.avatar
? `<img src="${contact.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`
: firstChar;
// 获取角色的独立API配置如果有的话
const hasCustomApi = contact.useCustomApi || false;
const customApiUrl = contact.customApiUrl || '';
const customApiKey = contact.customApiKey || '';
const customModel = contact.customModel || '';
const customHakimi = contact.customHakimiBreakLimit || false;
return `
<div class="wechat-group-contact-item" data-contact-id="${contact.id}" data-index="${index}" style="margin-bottom: 4px;">
<div class="wechat-group-contact-row" style="display: flex; align-items: center; padding: 10px; cursor: pointer; border-radius: 6px; background: var(--wechat-bg-secondary);">
<div style="width: 20px; height: 20px; margin-right: 10px;">
<input type="checkbox" class="wechat-group-contact-check" data-contact-id="${contact.id}" style="width: 18px; height: 18px; cursor: pointer;">
</div>
<div style="width: 40px; height: 40px; border-radius: 6px; overflow: hidden; background: var(--wechat-bg-tertiary, #3a3a3a); display: flex; align-items: center; justify-content: center; font-size: 16px; margin-right: 10px;">
${avatarHtml}
</div>
<div style="flex: 1; font-size: 14px;">${escapeHtml(contact.name)}</div>
<div class="wechat-group-api-toggle" style="font-size: 12px; color: var(--wechat-text-secondary); padding: 4px 8px;">
${hasCustomApi ? '⚙️' : '▼'}
</div>
</div>
<div class="wechat-group-contact-api-config hidden" data-contact-id="${contact.id}" style="padding: 12px; margin-top: 4px; background: var(--wechat-bg-tertiary, #2a2a2a); border-radius: 6px;">
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 10px;">为 ${escapeHtml(contact.name)} 配置独立API可选</div>
<div style="margin-bottom: 8px;">
<input type="text" class="wechat-settings-input wechat-group-api-url" placeholder="API 地址" value="${escapeHtml(customApiUrl)}" style="width: 100%; box-sizing: border-box; font-size: 12px;">
</div>
<div style="margin-bottom: 8px;">
<input type="password" class="wechat-settings-input wechat-group-api-key" placeholder="API 密钥" value="${escapeHtml(customApiKey)}" style="width: 100%; box-sizing: border-box; font-size: 12px;">
</div>
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
<select class="wechat-settings-input wechat-settings-select wechat-group-model" style="flex: 1; font-size: 12px;">
<option value="">-- 选择模型 --</option>
${customModel ? `<option value="${escapeHtml(customModel)}" selected>${escapeHtml(customModel)}</option>` : ''}
</select>
<button class="wechat-btn wechat-btn-small wechat-btn-primary wechat-group-fetch-model" style="font-size: 11px; padding: 4px 8px;">获取</button>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--wechat-border);">
<div style="font-size: 12px;">
<span style="color: #1e90ff;">哈基米破限</span>
<span style="color: var(--wechat-text-secondary); font-size: 10px; margin-left: 4px;">解除输出限制</span>
</div>
<div class="wechat-switch wechat-group-hakimi-toggle ${customHakimi ? 'on' : ''}" style="transform: scale(0.8);"></div>
</div>
</div>
</div>
`;
}).join('');
// 绑定点击事件
listContainer.querySelectorAll('.wechat-group-contact-item').forEach(item => {
const row = item.querySelector('.wechat-group-contact-row');
const checkbox = item.querySelector('input[type="checkbox"]');
const apiConfig = item.querySelector('.wechat-group-contact-api-config');
const apiToggle = item.querySelector('.wechat-group-api-toggle');
// 点击勾选框只切换选中状态
checkbox.addEventListener('click', (e) => {
e.stopPropagation();
const selectedCount = document.querySelectorAll('.wechat-group-contact-check:checked').length;
if (checkbox.checked && selectedCount > GROUP_CHAT_MAX_AI_MEMBERS) {
checkbox.checked = false;
showToast(`群聊最多只能选择 ${GROUP_CHAT_MAX_AI_MEMBERS} 个成员(+你=4`, '⚠️');
}
updateSelectedCount();
});
// 点击行的其他位置展开/收起API配置
row.addEventListener('click', (e) => {
if (e.target.type === 'checkbox') return;
// 先关闭其他展开的配置
listContainer.querySelectorAll('.wechat-group-contact-api-config').forEach(config => {
if (config !== apiConfig) {
config.classList.add('hidden');
const otherToggle = config.parentElement.querySelector('.wechat-group-api-toggle');
if (otherToggle && !otherToggle.textContent.includes('⚙️')) {
otherToggle.textContent = '▼';
}
}
});
// 切换当前配置的显示状态
apiConfig.classList.toggle('hidden');
if (!apiConfig.classList.contains('hidden')) {
apiToggle.textContent = '▲';
} else {
const contactId = item.dataset.contactId;
const contact = settings.contacts.find(c => c.id === contactId);
apiToggle.textContent = contact?.useCustomApi ? '⚙️' : '▼';
}
});
// 获取模型按钮
const fetchBtn = item.querySelector('.wechat-group-fetch-model');
fetchBtn?.addEventListener('click', async (e) => {
e.stopPropagation();
const urlInput = item.querySelector('.wechat-group-api-url');
const keyInput = item.querySelector('.wechat-group-api-key');
const modelSelect = item.querySelector('.wechat-group-model');
const apiUrl = urlInput?.value?.trim();
const apiKey = keyInput?.value?.trim();
if (!apiUrl) {
showToast('请先填写API地址', 'info');
return;
}
fetchBtn.textContent = '...';
fetchBtn.disabled = true;
try {
const { fetchModelListFromApi } = await import('./ai.js');
const models = await fetchModelListFromApi(apiUrl, apiKey);
if (models.length > 0) {
// 填充下拉列表
const currentValue = modelSelect.value;
modelSelect.innerHTML = '<option value="">-- 选择模型 --</option>' +
models.map(m => `<option value="${m}" ${m === currentValue ? 'selected' : ''}>${m}</option>`).join('');
showToast(`获取到 ${models.length} 个模型`);
} else {
showToast('未找到可用模型', 'info');
}
} catch (err) {
console.error('[可乐] 获取模型失败:', err);
showToast('获取失败,请手动输入', '⚠️');
} finally {
fetchBtn.textContent = '获取';
fetchBtn.disabled = false;
}
});
// 当API配置变化时自动保存到联系人
const saveApiConfig = () => {
const contactId = item.dataset.contactId;
const contact = settings.contacts.find(c => c.id === contactId);
if (!contact) return;
const urlInput = item.querySelector('.wechat-group-api-url');
const keyInput = item.querySelector('.wechat-group-api-key');
const modelSelect = item.querySelector('.wechat-group-model');
contact.customApiUrl = urlInput?.value?.trim() || '';
contact.customApiKey = keyInput?.value?.trim() || '';
contact.customModel = modelSelect?.value?.trim() || '';
contact.useCustomApi = !!(contact.customApiUrl && contact.customModel);
// 更新图标
apiToggle.textContent = contact.useCustomApi ? '⚙️' : '▼';
requestSave();
};
item.querySelector('.wechat-group-api-url')?.addEventListener('change', saveApiConfig);
item.querySelector('.wechat-group-api-key')?.addEventListener('change', saveApiConfig);
item.querySelector('.wechat-group-model')?.addEventListener('change', saveApiConfig);
// 哈基米破限开关
const hakimiToggle = item.querySelector('.wechat-group-hakimi-toggle');
hakimiToggle?.addEventListener('click', () => {
const contactId = item.dataset.contactId;
const contact = settings.contacts.find(c => c.id === contactId);
if (!contact) return;
hakimiToggle.classList.toggle('on');
contact.customHakimiBreakLimit = hakimiToggle.classList.contains('on');
requestSave();
});
});
}
// 清空群名输入
const nameInput = document.getElementById('wechat-group-name');
if (nameInput) nameInput.value = '';
// 重置选中计数
updateSelectedCount();
// 显示弹窗
document.getElementById('wechat-group-create-modal')?.classList.remove('hidden');
}
// 更新选中人数
function updateSelectedCount() {
const allCheckboxes = Array.from(document.querySelectorAll('.wechat-group-contact-check'));
const count = allCheckboxes.filter(cb => cb.checked).length;
const countEl = document.getElementById('wechat-group-selected-count');
const confirmBtn = document.getElementById('wechat-group-create-confirm');
if (countEl) countEl.textContent = `${count}/${GROUP_CHAT_MAX_AI_MEMBERS}`;
if (confirmBtn) confirmBtn.disabled = count < 2 || count > GROUP_CHAT_MAX_AI_MEMBERS;
// 达到上限后,禁用未选中的勾选框(防止继续选择)
allCheckboxes.forEach(cb => {
if (!cb.checked) {
cb.disabled = count >= GROUP_CHAT_MAX_AI_MEMBERS;
}
});
}
// 关闭群聊创建弹窗
export function closeGroupCreateModal() {
document.getElementById('wechat-group-create-modal')?.classList.add('hidden');
}
// 创建群聊
export function createGroupChat() {
const settings = getSettings();
// 获取选中的联系人
const checkboxes = document.querySelectorAll('.wechat-group-contact-check:checked');
const memberIds = Array.from(checkboxes).map(cb => cb.dataset.contactId);
if (memberIds.length < 2) {
showToast('请至少选择2个成员', '⚠️');
return;
}
if (memberIds.length > GROUP_CHAT_MAX_AI_MEMBERS) {
showToast(`群聊最多只能选择 ${GROUP_CHAT_MAX_AI_MEMBERS} 个成员(+你=4`, '⚠️');
return;
}
// 群聊必须全部使用独立 API每个成员一个独立后端
const invalidMembers = memberIds
.map(id => settings.contacts.find(c => c.id === id))
.filter(c => !c || !c.useCustomApi || !c.customApiUrl || !c.customModel);
if (invalidMembers.length > 0) {
const names = invalidMembers.map(c => c?.name || '未知').join('、');
showToast(`以下成员未配置独立API${names}`, '⚠️');
return;
}
// 获取群名
let groupName = document.getElementById('wechat-group-name')?.value?.trim();
// 如果没有输入群名,使用成员名称
if (!groupName) {
const memberNames = memberIds.map(id => {
const contact = settings.contacts.find(c => c.id === id);
return contact?.name || '未知';
});
groupName = memberNames.slice(0, 3).join('、');
if (memberNames.length > 3) groupName += '...';
}
// 创建群聊对象
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
const groupChat = {
id: 'group_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
name: groupName,
memberIds: memberIds,
chatHistory: [],
lastMessage: '',
lastMessageTime: Date.now(),
createdTime: timeStr
};
// 添加到群聊列表
if (!settings.groupChats) settings.groupChats = [];
settings.groupChats.push(groupChat);
requestSave();
refreshChatList();
closeGroupCreateModal();
showToast(`群聊"${groupName}"创建成功`);
// 打开新创建的群聊
const groupIndex = settings.groupChats.length - 1;
openGroupChat(groupIndex);
}
// 打开群聊界面
export function openGroupChat(groupIndex) {
console.log('[可乐] openGroupChat 被调用, groupIndex:', groupIndex);
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
if (!groupChat) return;
currentGroupChatIndex = groupIndex;
// 获取成员信息
const { memberIds } = enforceGroupChatMemberLimit(groupChat, { toast: true });
const members = memberIds.map(id =>
settings.contacts.find(c => c.id === id)
).filter(Boolean);
document.getElementById('wechat-main-content')?.classList.add('hidden');
document.getElementById('wechat-chat-page')?.classList.remove('hidden');
document.getElementById('wechat-chat-title').textContent = `群聊(${members.length + 1})`;
const messagesContainer = document.getElementById('wechat-chat-messages');
const chatHistory = groupChat.chatHistory || [];
if (chatHistory.length === 0) {
messagesContainer.innerHTML = '';
} else {
messagesContainer.innerHTML = renderGroupChatHistory(groupChat, members, chatHistory);
bindGroupVoiceBubbleEvents(messagesContainer);
bindGroupPhotoBubbleEvents(messagesContainer);
bindGroupMusicCardEvents(messagesContainer);
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 标记当前是群聊模式
messagesContainer.dataset.isGroup = 'true';
messagesContainer.dataset.groupIndex = groupIndex;
console.log('[可乐] 群聊标记已设置:', { isGroup: messagesContainer.dataset.isGroup, groupIndex: messagesContainer.dataset.groupIndex });
}
// 渲染群聊历史
function renderGroupChatHistory(groupChat, members, chatHistory) {
let html = '';
let lastTimestamp = 0;
const TIME_GAP_THRESHOLD = 5 * 60 * 1000;
chatHistory.forEach((msg, index) => {
const msgTimestamp = msg.timestamp || new Date(msg.time).getTime() || 0;
// 时间戳显示
if (index === 0 || (msgTimestamp - lastTimestamp > TIME_GAP_THRESHOLD)) {
const timeLabel = formatMessageTime(msgTimestamp);
if (timeLabel) {
html += `<div class="wechat-msg-time">${timeLabel}</div>`;
}
}
lastTimestamp = msgTimestamp;
// 检查是否是总结标记消息(和单聊逻辑一致)
if (msg.isMarker || msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
const markerText = msg.content || '可乐已加冰';
html += `<div class="wechat-msg-time">${escapeHtml(markerText)}</div>`;
return;
}
const isVoice = msg.isVoice === true;
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 ? `<div class="wechat-group-rp-designated-label">给${(rpInfo.targetMemberNames || []).join('、') || '指定成员'}的红包</div>` : '';
if (msg.role === 'user') {
html += `
<div class="wechat-message self">
<div class="wechat-message-avatar">${getUserAvatarHTML()}</div>
<div class="wechat-message-content">
<div class="wechat-group-red-packet-bubble ${statusClass}" data-rp-id="${rpInfo.id}">
<div class="wechat-group-rp-icon">
<svg viewBox="0 0 24 24" width="40" height="40"><rect x="4" y="2" width="16" height="20" rx="2" fill="#e74c3c"/><rect x="4" y="8" width="16" height="4" fill="#c0392b"/><circle cx="12" cy="10" r="3" fill="#f1c40f"/></svg>
</div>
<div class="wechat-group-rp-info">
<div class="wechat-group-rp-message">${escapeHtml(rpInfo.message || '恭喜发财,大吉大利')}</div>
${designatedLabel}
<div class="wechat-group-rp-status ${isClaimed ? '' : 'hidden'}">${isClaimed ? '已领完' : ''}</div>
</div>
</div>
<div class="wechat-group-rp-footer">群红包</div>
</div>
</div>
`;
}
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 += `
<div class="wechat-message self">
<div class="wechat-message-avatar">${getUserAvatarHTML()}</div>
<div class="wechat-message-content">
<div class="wechat-group-transfer-bubble ${statusClass}" data-tf-id="${tfInfo.id}">
<div class="wechat-group-tf-icon">
<svg viewBox="0 0 24 24" width="36" height="36"><rect x="2" y="4" width="20" height="16" rx="2" fill="#f39c12"/><text x="12" y="14" font-size="8" fill="#fff" text-anchor="middle">¥</text></svg>
</div>
<div class="wechat-group-tf-info">
<div class="wechat-group-tf-amount">¥${tfInfo.amount.toFixed(2)}</div>
<div class="wechat-group-tf-target">向${escapeHtml(tfInfo.targetMemberName)}转账</div>
<div class="wechat-group-tf-desc">${escapeHtml(tfInfo.description) || '转账'}</div>
</div>
<div class="wechat-group-tf-status">${statusText}</div>
</div>
</div>
</div>
`;
}
return;
}
if (msg.role === 'user') {
// 用户消息
let bubbleContent;
if (isSticker) {
bubbleContent = `<div class="wechat-sticker-bubble"><img src="${msg.content}" alt="表情" class="wechat-sticker-img"></div>`;
} else if (isPhoto) {
const photoId = 'photo_' + Math.random().toString(36).substring(2, 9);
bubbleContent = `
<div class="wechat-photo-bubble" data-photo-id="${photoId}">
<div class="wechat-photo-content" id="${photoId}-content">${escapeHtml(msg.content)}</div>
<div class="wechat-photo-blur" id="${photoId}-blur">
<div class="wechat-photo-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h12l-1.5 16H7.5L6 4z"/><path d="M6 4c0-1 1-2 6-2s6 1 6 2"/><path d="M9 8h6"/><circle cx="15" cy="6" r="0.5" fill="currentColor"/><circle cx="11" cy="7" r="0.5" fill="currentColor"/></svg>
</div>
<span class="wechat-photo-hint">点击查看</span>
</div>
</div>
`;
} else if (isVoice) {
bubbleContent = generateGroupVoiceBubbleStatic(msg.content, true);
} else if (isMusic && msg.musicInfo) {
// 音乐卡片
bubbleContent = generateGroupMusicCardStatic(msg.musicInfo);
} else {
const processedContent = parseMemeTag(msg.content);
const hasMeme = processedContent !== msg.content;
bubbleContent = `<div class="wechat-message-bubble">${hasMeme ? processedContent : escapeHtml(msg.content)}</div>`;
}
html += `
<div class="wechat-message self">
<div class="wechat-message-avatar">${getUserAvatarHTML()}</div>
<div class="wechat-message-content">${bubbleContent}</div>
</div>
`;
} else {
// 角色消息
// 优先通过角色ID匹配群聊里 name 可能重复/变更),找不到再回退到 name
const member = (msg.characterId && members.find(m => m.id === msg.characterId))
|| members.find(m => m.name === msg.characterName);
const charName = member?.name || msg.characterName || '未知';
const firstChar = charName.charAt(0);
const avatarContent = member?.avatar
? `<img src="${member.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar;
let bubbleContent;
if (isSticker) {
bubbleContent = `<div class="wechat-sticker-bubble"><img src="${msg.content}" alt="表情" class="wechat-sticker-img"></div>`;
} else if (isPhoto) {
const photoId = 'photo_' + Math.random().toString(36).substring(2, 9);
bubbleContent = `
<div class="wechat-photo-bubble" data-photo-id="${photoId}">
<div class="wechat-photo-content" id="${photoId}-content">${escapeHtml(msg.content)}</div>
<div class="wechat-photo-blur" id="${photoId}-blur">
<div class="wechat-photo-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h12l-1.5 16H7.5L6 4z"/><path d="M6 4c0-1 1-2 6-2s6 1 6 2"/><path d="M9 8h6"/><circle cx="15" cy="6" r="0.5" fill="currentColor"/><circle cx="11" cy="7" r="0.5" fill="currentColor"/></svg>
</div>
<span class="wechat-photo-hint">点击查看</span>
</div>
</div>
`;
} else if (isVoice) {
bubbleContent = generateGroupVoiceBubbleStatic(msg.content, false);
} else if (isMusic && msg.musicInfo) {
// 音乐卡片
bubbleContent = generateGroupMusicCardStatic(msg.musicInfo);
} else {
const processedContent = parseMemeTag(msg.content);
const hasMeme = processedContent !== msg.content;
bubbleContent = `<div class="wechat-message-bubble">${hasMeme ? processedContent : escapeHtml(msg.content)}</div>`;
}
html += `
<div class="wechat-message wechat-message-group">
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content">
<div class="wechat-message-sender" style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 2px;">${escapeHtml(charName)}</div>
${bubbleContent}
</div>
</div>
`;
}
});
return html;
}
// 生成群聊静态语音气泡
function generateGroupVoiceBubbleStatic(content, isSelf) {
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信号样式的三条弧线图标与单聊保持一致
const wavesSvg = `<svg class="wechat-voice-waves-icon" viewBox="0 0 24 24" width="18" height="18">
<circle class="wechat-voice-arc arc1" cx="5" cy="12" r="2" fill="currentColor"/>
<path class="wechat-voice-arc arc2" d="M10 8 A 5 5 0 0 1 10 16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
<path class="wechat-voice-arc arc3" d="M15 4 A 10 10 0 0 1 15 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>`;
// 用户消息:时长在左,波形在右
// 角色消息:波形在左,时长在右
const bubbleInner = isSelf
? `<span class="wechat-voice-duration">${seconds}"</span><span class="wechat-voice-waves">${wavesSvg}</span>`
: `<span class="wechat-voice-waves">${wavesSvg}</span><span class="wechat-voice-duration">${seconds}"</span>`;
return `
<div class="wechat-voice-bubble ${isSelf ? 'self' : ''}" style="width: ${width}px" data-voice-id="${voiceId}" data-voice-content="${escapeHtml(safeContent)}">
${bubbleInner}
</div>
<div class="wechat-voice-text hidden" id="${voiceId}">${escapeHtml(safeContent)}</div>
`;
}
// 生成群聊静态音乐卡片(用于历史消息渲染)
function generateGroupMusicCardStatic(musicInfo) {
const name = musicInfo?.name || '未知歌曲';
const artist = musicInfo?.artist || '未知歌手';
const cover = musicInfo?.cover || '';
const platform = musicInfo?.platform || '';
const songId = musicInfo?.id || '';
const platformName = platform === 'netease' ? '网易云音乐' :
platform === 'qq' ? 'QQ音乐' :
platform === 'kuwo' ? '酷我音乐' : '音乐';
const cardId = 'music_card_' + Math.random().toString(36).substring(2, 9);
return `
<div class="wechat-music-card" id="${cardId}" data-song-id="${escapeHtml(songId)}" data-platform="${escapeHtml(platform)}" data-name="${escapeHtml(name)}" data-artist="${escapeHtml(artist)}" data-cover="${escapeHtml(cover)}">
<div class="wechat-music-card-cover">
<img src="${cover}" alt="" onerror="this.style.display='none'">
</div>
<div class="wechat-music-card-info">
<div class="wechat-music-card-name">${escapeHtml(name)}</div>
<div class="wechat-music-card-artist">${escapeHtml(artist)}</div>
</div>
<div class="wechat-music-card-play">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="6,4 20,12 6,20"/></svg>
</div>
</div>
<div class="wechat-music-card-footer">${escapeHtml(platformName)}</div>
`;
}
// 绑定群聊语音气泡点击事件(播放动画 + 显示上方菜单,与单聊保持一致)
function bindGroupVoiceBubbleEvents(container) {
const voiceBubbles = container.querySelectorAll('.wechat-voice-bubble:not([data-bound])');
voiceBubbles.forEach(bubble => {
bubble.setAttribute('data-bound', 'true');
// 获取父消息元素
const messageEl = bubble.closest('.wechat-message');
// 计算消息索引
const allMessages = Array.from(container.querySelectorAll('.wechat-message'));
const msgIndex = allMessages.indexOf(messageEl);
// 点击事件:播放动画 + 显示上方菜单
bubble.addEventListener('click', (e) => {
e.stopPropagation();
// 切换播放状态
const isPlaying = bubble.classList.contains('playing');
if (isPlaying) {
bubble.classList.remove('playing');
} else {
// 停止其他正在播放的语音
document.querySelectorAll('.wechat-voice-bubble.playing').forEach(b => {
b.classList.remove('playing');
});
bubble.classList.add('playing');
// 模拟播放时间后停止
const duration = parseInt(bubble.querySelector('.wechat-voice-duration')?.textContent) || 3;
setTimeout(() => {
bubble.classList.remove('playing');
}, duration * 1000);
}
// 显示上方菜单
showMessageMenu(bubble, msgIndex, e);
});
});
}
// 绑定群聊照片气泡点击事件toggle切换蒙层
function bindGroupPhotoBubbleEvents(container) {
const photoBubbles = container.querySelectorAll('.wechat-photo-bubble:not([data-bound])');
photoBubbles.forEach(bubble => {
bubble.setAttribute('data-bound', 'true');
bubble.addEventListener('click', () => {
const photoId = bubble.dataset.photoId;
const blurEl = document.getElementById(`${photoId}-blur`);
if (blurEl) {
blurEl.classList.toggle('hidden');
}
});
});
}
// 绑定群聊音乐卡片点击事件
function bindGroupMusicCardEvents(container) {
const musicCards = container.querySelectorAll('.wechat-music-card:not([data-bound])');
musicCards.forEach(card => {
card.setAttribute('data-bound', 'true');
card.addEventListener('click', function() {
const id = this.dataset.songId;
const plat = this.dataset.platform;
const n = this.dataset.name;
const a = this.dataset.artist;
if (id && plat) {
kugouPlayMusic(id, plat, n, a);
}
});
});
}
// 追加群聊消息到界面
export function appendGroupMessage(role, content, characterName, characterId, isVoice = false, isSticker = false) {
const settings = getSettings();
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
if (role === 'user') {
messageDiv.className = 'wechat-message self';
let bubbleContent;
if (isSticker) {
bubbleContent = `<div class="wechat-sticker-bubble"><img src="${content}" alt="表情" class="wechat-sticker-img"></div>`;
} else if (isVoice) {
const seconds = calculateVoiceDuration(content);
const width = Math.min(50 + seconds * 3, 180);
const voiceId = 'voice_' + Math.random().toString(36).substring(2, 9);
// 用户消息:波形在左,秒数在右
bubbleContent = `
<div class="wechat-voice-bubble self" style="width: ${width}px" data-voice-id="${voiceId}">
<span class="wechat-voice-waves"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 12h2v4H3zM7 8h2v8H7zm4 4h2v6h-2zm4-6h2v10h-2z" fill="currentColor"/></svg></span>
<span class="wechat-voice-duration">${seconds}</span>
</div>
<div class="wechat-voice-text hidden" id="${voiceId}">${escapeHtml(content)}</div>
`;
} else {
const processedContent = parseMemeTag(content);
const hasMeme = processedContent !== content;
bubbleContent = `<div class="wechat-message-bubble">${hasMeme ? processedContent : escapeHtml(content)}</div>`;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${getUserAvatarHTML()}</div>
<div class="wechat-message-content">${bubbleContent}</div>
`;
} else {
// 角色消息
messageDiv.className = 'wechat-message wechat-message-group';
// 优先用角色ID匹配群聊里 name 可能重复/变更),找不到再回退到 name
const member = (characterId && settings.contacts.find(c => c.id === characterId))
|| settings.contacts.find(c => c.name === characterName);
const charName = member?.name || characterName || '未知';
if (GROUP_CHAT_DEBUG) {
console.log('[可乐] appendGroupMessage:', { characterName, characterId, resolvedName: member?.name });
}
const firstChar = charName.charAt(0);
const avatarContent = member?.avatar
? `<img src="${member.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar;
let bubbleContent;
if (isSticker) {
bubbleContent = `<div class="wechat-sticker-bubble"><img src="${content}" alt="表情" class="wechat-sticker-img"></div>`;
} else if (isVoice) {
const seconds = calculateVoiceDuration(content);
const width = Math.min(50 + seconds * 3, 180);
const voiceId = 'voice_' + Math.random().toString(36).substring(2, 9);
// 角色消息:秒数在左,波形在右
bubbleContent = `
<div class="wechat-voice-bubble" style="width: ${width}px" data-voice-id="${voiceId}">
<span class="wechat-voice-duration">${seconds}</span>
<span class="wechat-voice-waves"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M19 12h2v4h-2zm-4-4h2v8h-2zm-4 4h2v6h-2zm-4-6h2v10H7z" fill="currentColor"/></svg></span>
</div>
<div class="wechat-voice-text hidden" id="${voiceId}">${escapeHtml(content)}</div>
`;
} else {
const processedContent = parseMemeTag(content);
const hasMeme = processedContent !== content;
bubbleContent = `<div class="wechat-message-bubble">${hasMeme ? processedContent : escapeHtml(content)}</div>`;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content">
<div class="wechat-message-sender" style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 2px;">${escapeHtml(charName)}</div>
${bubbleContent}
</div>
`;
}
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
if (isVoice) {
bindGroupVoiceBubbleEvents(messagesContainer);
}
}
// 追加群聊音乐卡片消息到界面
export function appendGroupMusicCardMessage(role, song) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : 'wechat-message-group'}`;
const name = song?.name || '未知歌曲';
const artist = song?.artist || '未知歌手';
const cover = song?.cover || '';
const platform = song?.platform || '';
const songId = song?.id || '';
const platformName = platform === 'netease' ? '网易云音乐' :
platform === 'qq' ? 'QQ音乐' :
platform === 'kuwo' ? '酷我音乐' : '音乐';
const cardId = 'music_card_' + Math.random().toString(36).substring(2, 9);
const musicCardHTML = `
<div class="wechat-music-card" id="${cardId}" data-song-id="${escapeHtml(songId)}" data-platform="${escapeHtml(platform)}" data-name="${escapeHtml(name)}" data-artist="${escapeHtml(artist)}" data-cover="${escapeHtml(cover)}">
<div class="wechat-music-card-cover">
<img src="${cover}" alt="" onerror="this.style.display='none'">
</div>
<div class="wechat-music-card-info">
<div class="wechat-music-card-name">${escapeHtml(name)}</div>
<div class="wechat-music-card-artist">${escapeHtml(artist)}</div>
</div>
<div class="wechat-music-card-play">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="6,4 20,12 6,20"/></svg>
</div>
</div>
<div class="wechat-music-card-footer">${escapeHtml(platformName)}</div>
`;
if (role === 'user') {
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${getUserAvatarHTML()}</div>
<div class="wechat-message-content">${musicCardHTML}</div>
`;
} else {
messageDiv.innerHTML = `
<div class="wechat-message-avatar"></div>
<div class="wechat-message-content">${musicCardHTML}</div>
`;
}
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 绑定音乐卡片点击事件
const card = document.getElementById(cardId);
if (card) {
card.addEventListener('click', function() {
const id = this.dataset.songId;
const plat = this.dataset.platform;
const n = this.dataset.name;
const a = this.dataset.artist;
if (id && plat) {
kugouPlayMusic(id, plat, n, a);
}
});
}
}
// 显示群聊打字指示器
export function showGroupTypingIndicator(characterName, characterId = null) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
hideGroupTypingIndicator();
const settings = getSettings();
const member = (characterId && settings.contacts.find(c => c.id === characterId))
|| settings.contacts.find(c => c.name === characterName);
const displayName = member?.name || characterName || '群成员';
const firstChar = displayName?.charAt(0) || '?';
const avatarContent = member?.avatar
? `<img src="${member.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar;
const typingDiv = document.createElement('div');
typingDiv.className = 'wechat-message wechat-typing-wrapper wechat-message-group';
typingDiv.id = 'wechat-group-typing-indicator';
typingDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content">
<div class="wechat-message-sender" style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 2px;">${escapeHtml(displayName)}</div>
<div class="wechat-message-bubble wechat-typing">
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
</div>
</div>
`;
messagesContainer.appendChild(typingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 隐藏群聊打字指示器
export function hideGroupTypingIndicator() {
const indicator = document.getElementById('wechat-group-typing-indicator');
if (indicator) indicator.remove();
}
// 构建群聊系统提示词
export function buildGroupSystemPrompt(groupChat, members, silentCharacters = []) {
const settings = getSettings();
let systemPrompt = '';
// 哈基米破限(使用全局设置)
if (settings.hakimiBreakLimit) {
// 优先使用自定义破限词
systemPrompt += settings.hakimiCustomPrompt || HAKIMI_HEADER;
}
// 酒馆上下文
const contextLevel = settings.contextLevel ?? 5;
const stContext = getSTChatContext(contextLevel);
if (stContext) {
systemPrompt += stContext + '\n';
}
// 用户设定
const personaBlock = buildUserPersonaBlock(settings);
if (personaBlock) {
systemPrompt += personaBlock + '\n\n';
}
// ========== 采用和单聊一样的简单逻辑 ==========
// 全局世界书(只读取 fromCharacter: false 的,角色书直接从 charData.character_book 读取)
const globalLorebookEntries = [];
const selectedLorebooks = settings.selectedLorebooks || [];
selectedLorebooks.forEach(lb => {
// 检查世界书是否启用
if (lb.enabled === false || lb.enabled === 'false') return;
// 跳过角色卡自带的世界书(下面会直接从每个角色的 charData.character_book 读取)
if (lb.fromCharacter) return;
// 只读取全局世界书
(lb.entries || []).forEach(entry => {
if (entry.enabled !== false && entry.enabled !== 'false' && entry.disable !== true && entry.content) {
globalLorebookEntries.push(entry.content);
}
});
});
if (globalLorebookEntries.length > 0) {
systemPrompt += `【共享世界观】\n`;
globalLorebookEntries.forEach(content => {
// 替换世界书中的 {{user}} 占位符
systemPrompt += `- ${replacePromptPlaceholders(content)}\n`;
});
systemPrompt += '\n';
}
// 群聊成员信息(每个角色带自己的角色书)
systemPrompt += `【群聊成员】\n`;
systemPrompt += `这是一个包含 ${members.length} 位角色的群聊。每个角色只能使用自己的设定,不能使用其他角色的设定。\n\n`;
members.forEach((member, idx) => {
const rawData = member.rawData || {};
const charData = rawData.data || rawData;
const charName = charData.name || member.name;
systemPrompt += `=== 角色 ${idx + 1}: ${charName} ===\n`;
// 替换角色描述和性格中的 {{user}} 占位符
if (charData.description) systemPrompt += `描述:${replacePromptPlaceholders(charData.description)}\n`;
if (charData.personality) systemPrompt += `性格:${replacePromptPlaceholders(charData.personality)}\n`;
// 直接从角色卡数据读取角色书(和单聊一样的逻辑)
if (charData.character_book?.entries?.length > 0) {
const enabledEntries = charData.character_book.entries.filter(entry =>
entry.enabled !== false && entry.disable !== true
);
if (enabledEntries.length > 0) {
systemPrompt += `[${charName}专属设定 - 仅该角色可用]\n`;
enabledEntries.forEach(entry => {
// 替换角色书中的 {{user}} 占位符
if (entry.content) systemPrompt += ` · ${replacePromptPlaceholders(entry.content)}\n`;
});
}
}
systemPrompt += '\n';
});
// 保底机制:标注沉默太久的角色
if (silentCharacters.length > 0) {
systemPrompt += `【保底提醒】\n`;
systemPrompt += `以下角色已经沉默太久连续4次用户发言都没有回复本次回复中必须包含他们的发言\n`;
silentCharacters.forEach(name => {
systemPrompt += `- ${name}\n`;
});
systemPrompt += '\n';
}
// 群聊专用提示词(优先使用用户自定义,否则使用内置模板)
if (settings.groupAutoInjectPrompt) {
const groupPrompt = settings.userGroupAuthorNote || settings.groupAuthorNote;
if (groupPrompt) {
systemPrompt += groupPrompt + '\n\n';
}
}
// 用户表情包功能(仅在启用时添加)
const userStickers = getUserStickers(settings);
if (settings.userStickersEnabled !== false && userStickers.length > 0) {
systemPrompt += `【表情包功能】
群成员们有 ${userStickers.length} 个共享表情包可以使用!
发送格式(任选其一):
- [角色名]: [表情:序号]序号从1开始
- [角色名]: [表情:表情包名称](推荐:从列表复制名称,避免数错)
可用表情包列表:
${userStickers.map((s, i) => ` ${i + 1}. ${s.name || '表情' + (i + 1)}`).join('\n')}
使用建议:
- 根据表情包名称选择合适的表情
- 适当时候发送表情包,让聊天更生动
- 表情包必须单独一条消息发送
- 发送格式示例:[角色A]: [表情:1] 或 [角色A]: [表情:${userStickers[0]?.name || '表情1'}]
`;
}
// Meme 表情包提示词(如果启用)
if (settings.memeStickersEnabled) {
systemPrompt += '\n\n' + MEME_PROMPT_TEMPLATE;
}
return systemPrompt;
}
// 构建群聊消息列表
export function buildGroupMessages(groupChat, members, userMessage, silentCharacters = []) {
const systemPrompt = buildGroupSystemPrompt(groupChat, members, silentCharacters);
const chatHistory = groupChat.chatHistory || [];
const messages = [{ role: 'system', content: systemPrompt }];
// 添加历史消息
const recentHistory = chatHistory.slice(-300);
recentHistory.forEach(msg => {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content });
} else {
const formattedContent = msg.characterName
? `[${msg.characterName}]: ${msg.content}`
: msg.content;
messages.push({ role: 'assistant', content: formattedContent });
}
});
messages.push({ role: 'user', content: userMessage });
return messages;
}
// 解析群聊 AI 回复
export function parseGroupResponse(response, members) {
const results = [];
const settings = getSettings();
const memeRegex = /<\s*meme\s*>\s*[\u4e00-\u9fa5]*?[a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif)\s*<\s*\/\s*meme\s*>/gi;
// 按 ||| 分隔多条消息
const parts = response.split('|||').map(p => p.trim()).filter(p => p);
// 辅助函数:分割内容中的 meme 标签
const splitContentByMeme = (content) => {
const memeMatches = content.match(memeRegex);
if (!memeMatches) return [content];
const contentParts = [];
let remaining = content;
for (const meme of memeMatches) {
const memeIndex = remaining.indexOf(meme);
if (memeIndex > 0) {
const before = remaining.substring(0, memeIndex).trim();
if (before) contentParts.push(before);
}
contentParts.push(meme);
remaining = remaining.substring(memeIndex + meme.length);
}
remaining = remaining.trim();
if (remaining) contentParts.push(remaining);
return contentParts.filter(p => p);
};
parts.forEach(part => {
// 匹配 [角色名]: 内容 格式
const match = part.match(/^\[(.+?)\][:]\s*(.+)$/s);
if (match) {
const charName = match[1].trim();
let content = match[2].trim();
// 查找对应的联系人(更宽松的匹配)
const member = members.find(m => {
const memberName = m.name?.trim().toLowerCase();
const rawName = m.rawData?.data?.name?.trim().toLowerCase();
const rawName2 = m.rawData?.name?.trim().toLowerCase();
const searchName = charName.trim().toLowerCase();
return memberName === searchName ||
rawName === searchName ||
rawName2 === searchName ||
memberName?.includes(searchName) ||
searchName.includes(memberName);
});
const characterId = member?.id || null;
const characterName = member?.name || charName;
// 检查内容是否包含 meme 标签与其他文字混合
const contentParts = splitContentByMeme(content);
for (const contentPart of contentParts) {
let finalContent = contentPart;
let isVoice = false;
let isSticker = false;
let stickerUrl = null;
// 检查是否是语音消息
const voiceMatch = finalContent.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
finalContent = voiceMatch[1];
isVoice = true;
}
// 检查是否是表情包消息 [表情:序号] / [表情:名称]
const stickerMatch = finalContent.match(/^\[表情[:]\s*(.+?)\]$/);
if (stickerMatch) {
const token = (stickerMatch[1] || '').trim();
stickerUrl = resolveUserStickerUrl(token, settings);
if (stickerUrl) {
finalContent = stickerUrl;
isSticker = true;
}
}
results.push({
characterId,
characterName,
content: finalContent,
isVoice,
isSticker
});
}
} else {
// 无法解析格式时,尝试作为第一个角色的消息
if (members.length > 0) {
// 同样检查 meme 分割
const contentParts = splitContentByMeme(part);
for (const contentPart of contentParts) {
results.push({
characterId: members[0].id,
characterName: members[0].name,
content: contentPart,
isVoice: false,
isSticker: false
});
}
}
}
});
return results;
}
// 调用单个角色的 AI必须使用角色独立 API 配置)
async function callSingleCharacterAI(member, groupChat, members, userMessage, silentCharacters = [], currentRoundResponses = []) {
const settings = getSettings();
// 必须使用角色独立配置,不再回退到群聊/单聊API
if (!member.useCustomApi || !member.customApiUrl || !member.customModel) {
throw new Error(`角色「${member.name}」未配置独立API无法参与群聊`);
}
const apiUrl = member.customApiUrl;
const apiKey = member.customApiKey || '';
const apiModel = member.customModel;
// 构建针对单个角色的系统提示词
const systemPrompt = buildSingleCharacterPrompt(member, groupChat, members, silentCharacters);
const messages = [{ role: 'system', content: systemPrompt }];
// 添加历史消息(限长:避免 system/用户设定被挤掉)
const chatHistory = getGroupChatHistoryForApi(groupChat.chatHistory);
chatHistory.forEach(msg => {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content });
return;
}
// 关键:只把“本角色自己”的历史作为 assistant其它角色的发言作为 user 注入,
// 否则模型会误以为“自己assistant曾经说过别人的台词”极易串台/口吻漂移。
const isSelfAssistant = (msg.characterId && msg.characterId === member.id) ||
(!msg.characterId && msg.characterName === member.name);
if (isSelfAssistant) {
messages.push({ role: 'assistant', content: msg.content });
return;
}
const formattedContent = msg.characterName
? `[${msg.characterName}]: ${msg.content}`
: msg.content;
messages.push({ role: 'user', content: formattedContent });
});
// 关键兼容:把“用户设定/世界书 + 本轮用户消息 + 当前轮已产生的群友回复”合并到同一条最后一条user 消息里,
// 避免部分后端只取最后一条 user 导致后续角色丢失设定/世界书。
const userMessageParts = [];
if (GROUP_CHAT_PERSONA_PREAMBLE_ENABLED) {
const personaPreamble = buildUserPersonaPreamble(settings, member);
if (personaPreamble) userMessageParts.push(personaPreamble);
}
userMessageParts.push(userMessage);
if (currentRoundResponses.length > 0) {
const otherRoundResponses = currentRoundResponses.filter(r => r.characterId !== member.id);
if (otherRoundResponses.length > 0) {
const roundContent = otherRoundResponses
.slice(-30)
.map((r, idx) => `${idx + 1}. [${r.characterName}]: ${r.content}`)
.join('\n');
userMessageParts.push(`【其他群成员刚才的回复】
${roundContent}
(现在轮到你 ${member.name} 发言)
【重要】你的回复会和上面的消息交错显示!
- 你的第1条消息会显示在别人第1条后面
- 你的第2条消息会显示在别人第2条后面
- 以此类推...
所以请按顺序回应先回应第1条再回应第2条...确保交错后语义通顺。
如果某条不需要回应,可以跳过或用简短回应(如"嗯")占位。`);
}
}
const finalUserMessage = userMessageParts.filter(Boolean).join('\n\n');
messages.push({ role: 'user', content: finalUserMessage });
const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(chatUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: apiModel,
messages,
temperature: 1,
max_tokens: 8196
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API 错误 (${response.status}): ${errorText.substring(0, 100)}`);
}
const data = await response.json();
let rawResponse = data.choices?.[0]?.message?.content || '';
// 获取所有其他角色的名字(用于过滤串台)
const otherMemberNames = members.filter(m => m.id !== member.id).map(m => m.name);
// 清理响应,移除可能的角色名前缀(包括自己的)
rawResponse = rawResponse.replace(/^\[.+?\][:]\s*/s, '').trim();
// 辅助函数:检查内容是否属于其他角色
const isOtherCharacterContent = (text) => {
for (const name of otherMemberNames) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 检查是否以其他角色名开头
if (text.startsWith(`[${name}]`) ||
text.match(new RegExp(`^\\[${escapedName}\\][:]`)) ||
text.startsWith(`${name}:`) ||
text.startsWith(`${name}`)) {
return true;
}
}
return false;
};
// 辅助函数:清理内容中的角色前缀
const cleanPrefix = (text) => {
let cleaned = text.replace(/^\[.+?\][:]\s*/s, '').trim();
// 也移除自己名字的前缀
const selfPattern = new RegExp(`^${member.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[:]\\s*`);
cleaned = cleaned.replace(selfPattern, '').trim();
return cleaned;
};
// 辅助函数:截断到其他角色内容之前
const truncateAtOtherCharacter = (text) => {
let result = text;
for (const name of otherMemberNames) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 检查中间是否有其他角色的发言
const patterns = [
new RegExp(`\\s*\\[${escapedName}\\][:]`),
new RegExp(`\\s*${escapedName}[:]`)
];
for (const pattern of patterns) {
const match = result.match(pattern);
if (match && match.index > 0) {
result = result.substring(0, match.index).trim();
}
}
}
return result;
};
// 过滤掉其他角色的内容
if (rawResponse.includes('|||')) {
const parts = rawResponse.split('|||').map(p => p.trim()).filter(p => p);
const filteredParts = [];
for (const part of parts) {
// 跳过完全属于其他角色的部分
if (isOtherCharacterContent(part)) {
continue;
}
// 清理前缀并截断
let cleaned = cleanPrefix(part);
cleaned = truncateAtOtherCharacter(cleaned);
if (cleaned) {
filteredParts.push(cleaned);
}
}
rawResponse = filteredParts.join('|||');
} else {
// 单条消息
if (isOtherCharacterContent(rawResponse)) {
rawResponse = '';
} else {
rawResponse = cleanPrefix(rawResponse);
rawResponse = truncateAtOtherCharacter(rawResponse);
}
}
// 检查是否是语音消息
let isVoice = false;
const voiceMatch = rawResponse.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
rawResponse = voiceMatch[1];
isVoice = true;
}
// 如果过滤后为空,生成一个默认回复
if (!rawResponse || !rawResponse.trim()) {
// 使用原始响应的第一部分(去掉角色名前缀)
const originalContent = data.choices?.[0]?.message?.content || '';
const firstPart = originalContent.split('|||')[0]?.trim() || '';
const cleanedFirst = firstPart.replace(/^\[.+?\][:]\s*/s, '').trim();
if (cleanedFirst && !isOtherCharacterContent(cleanedFirst)) {
rawResponse = cleanedFirst;
}
}
return {
characterId: member.id,
characterName: member.name,
content: rawResponse,
isVoice
};
}
// 构建单角色系统提示词
function buildSingleCharacterPrompt(member, groupChat, members, silentCharacters = []) {
const settings = getSettings();
// 调试日志:检查角色数据结构
const rawData = member.rawData || {};
const charData = rawData.data || rawData;
if (GROUP_CHAT_DEBUG) {
console.log('[可乐] buildSingleCharacterPrompt 角色数据:', {
memberName: member.name,
hasRawData: !!member.rawData,
rawDataKeys: Object.keys(rawData),
charDataKeys: Object.keys(charData),
hasCharacterBook: !!charData.character_book,
characterBookEntriesCount: charData.character_book?.entries?.length || 0,
characterBookEntries: charData.character_book?.entries?.map(e => ({
content: e.content?.substring(0, 50),
enabled: e.enabled,
disable: e.disable
}))
});
}
let systemPrompt = '';
// 哈基米破限
const useHakimi = member.customHakimiBreakLimit ?? settings.hakimiBreakLimit;
if (useHakimi) {
// 优先使用自定义破限词
systemPrompt += settings.hakimiCustomPrompt || HAKIMI_HEADER;
}
// 酒馆上下文
const contextLevel = settings.contextLevel ?? 5;
const stContext = getSTChatContext(contextLevel);
if (stContext) {
systemPrompt += stContext + '\n';
}
// 用户设定:同时放入 system更强约束+ user preamble兼容部分后端忽略 system / 只取最后一条 user
const personaBlock = buildUserPersonaBlock(settings);
if (personaBlock) systemPrompt += personaBlock + '\n\n';
// 当前角色信息rawData 和 charData 已在函数开头定义)
const charName = charData.name || member.name;
// ========== 采用和单聊一样的简单逻辑 ==========
// 1. 直接从 charData.character_book 读取角色书(不依赖匹配)
// 2. 从 selectedLorebooks 只读取全局世界书(跳过 fromCharacter
// 全局世界书(非角色卡自带的世界书,供所有角色共享)
const selectedLorebooks = settings.selectedLorebooks || [];
const globalLorebookEntries = [];
selectedLorebooks.forEach(lb => {
if (!lb || lb.fromCharacter) return;
if (!isLorebookEnabled(lb)) return;
(lb.entries || []).forEach(entry => {
if (!entry?.content) return;
if (!isLorebookEntryEnabled(entry)) return;
globalLorebookEntries.push(entry.content);
});
});
if (globalLorebookEntries.length > 0) {
systemPrompt += `【共享世界观】\n`;
globalLorebookEntries.forEach(content => {
// 替换世界书中的 {{user}} 占位符
systemPrompt += `- ${replacePromptPlaceholders(content)}\n`;
});
systemPrompt += '\n';
}
systemPrompt += `【你扮演的角色】\n`;
systemPrompt += `你是 ${charName}\n`;
// 替换角色描述和性格中的 {{user}} 占位符
if (charData.description) systemPrompt += `描述:${replacePromptPlaceholders(charData.description)}\n`;
if (charData.personality) systemPrompt += `性格:${replacePromptPlaceholders(charData.personality)}\n`;
// 角色专属世界书:优先使用 selectedLorebooks 的 fromCharacter尊重启用/关闭开关),回退到 rawData.character_book
const characterLorebook = findCharacterLorebookForMember(member, settings);
let characterBookContents = [];
if (characterLorebook) {
if (isLorebookEnabled(characterLorebook)) {
characterBookContents = (characterLorebook.entries || [])
.filter(entry => entry?.content && isLorebookEntryEnabled(entry))
.map(entry => entry.content);
} else {
characterBookContents = null; // 该角色世界书被关闭:完全不注入
}
}
if (characterBookContents !== null) {
if (characterBookContents.length === 0 && charData.character_book?.entries?.length > 0) {
characterBookContents = charData.character_book.entries
.filter(entry => entry?.content && isLorebookEntryEnabled(entry))
.map(entry => entry.content);
}
const uniqueCharacterEntries = Array.from(new Set(characterBookContents.map(c => (c || '').trim()).filter(Boolean)));
if (uniqueCharacterEntries.length > 0) {
systemPrompt += `\n${charName}专属设定】\n`;
uniqueCharacterEntries.forEach(content => {
systemPrompt += `- ${replacePromptPlaceholders(content)}\n`;
});
}
}
systemPrompt += '\n';
// 群聊其他成员信息(简略,不包含他们的角色书)
systemPrompt += `【群聊其他成员】\n`;
members.forEach(m => {
if (m.id !== member.id) {
systemPrompt += `- ${m.name}\n`;
}
});
systemPrompt += '\n';
// 回复格式 - 类似单聊规则,放宽限制
systemPrompt += `【回复格式】
你正在微信群聊中,请以 ${member.name} 的身份回复。
规则:
1. 直接输出对话内容,不要加角色名前缀
2. 你可以发送1-2条消息每条消息之间用 ||| 分隔
3. 每条消息保持简短自然像真实微信聊天一样1-3句话为宜
4. 保持角色性格特点,回复要符合你的人设
5. 可以使用表情符号
6. 必须回复至少一条消息,哪怕只是"嗯"、"哦"、表情符号等简短回应
7. 语音消息格式:[语音:内容]
8. 语音消息必须独立发送,不能和其他消息混在一起
【交错显示机制】
群聊中各角色的消息会交错显示你的第1条、别人的第1条、你的第2条、别人的第2条...
所以如果你要回应别人的多条消息,请按对方消息的顺序依次回应,确保交错后对话通顺。
示例(普通多条消息):
哈哈你说得对|||我也这么觉得
示例(语音消息):
[语音:哎呀笑死我了你们太搞笑了]
【重要规则】
× 只能以 ${member.name} 的身份说话,禁止代替其他群成员发言
× 不要使用 [角色名]: 格式,直接输出对话内容
× 不要输出空内容,必须回复
√ 可以@其他群成员互动,如"@xxx 你觉得呢"
√ 可以对其他群成员的发言进行回应、吐槽、附和等
`;
// 保底机制提醒
if (silentCharacters.includes(member.name)) {
systemPrompt += `\n【提醒】你已经沉默很久了,这次请务必回复!\n`;
}
if (GROUP_CHAT_DEBUG) {
console.log('[可乐] buildSingleCharacterPrompt 最终提示词:', {
角色: member.name,
提示词长度: systemPrompt.length,
用户设定注入方式: GROUP_CHAT_PERSONA_PREAMBLE_ENABLED ? 'user_role_preamble' : 'system_prompt',
提示词预览: systemPrompt.substring(0, 500)
});
}
return systemPrompt;
}
// 调用群聊 AI支持每个角色独立 API
export async function callGroupAI(groupChat, members, userMessage, silentCharacters = []) {
const settings = getSettings();
// 始终使用独立调用模式为每个角色单独调用AI
// 使用群聊API来决定发言顺序
const speakingOrder = await determineSpeakingOrder(groupChat, members, userMessage, silentCharacters);
// 为每个角色收集消息(用于交错显示)
const memberMessages = {}; // { memberName: [msg1, msg2, ...] }
const currentRoundResponses = []; // 当前轮次已产生的回复
// 后台静默处理所有 AI 响应(不显示 typing 指示器)
for (const memberName of speakingOrder) {
const member = members.find(m => m.name === memberName);
if (!member) continue;
memberMessages[memberName] = [];
// 最多重试5次
const MAX_RETRIES = 5;
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// 传入当前轮次已有的回复,让后面的角色能看到前面的发言
const response = await callSingleCharacterAI(member, groupChat, members, userMessage, silentCharacters, currentRoundResponses);
// 调试日志:检查 AI 返回的角色信息
if (GROUP_CHAT_DEBUG) {
console.log('[可乐] callSingleCharacterAI 返回:', {
expectedMember: memberName,
returnedId: response.characterId,
returnedName: response.characterName,
content: response.content?.substring(0, 50),
attempt
});
}
// 只有非空响应才添加
if (response.content && response.content.trim()) {
// 使用智能分割(处理 ||| 和 meme 标签)
const parts = splitAIMessages(response.content);
for (const part of parts) {
let partContent = part;
let partIsVoice = false;
// 检查每个部分是否是语音
const voiceMatch = part.match(/^\[语音[:]\s*(.+?)\]$/);
if (voiceMatch) {
partContent = voiceMatch[1];
partIsVoice = true;
}
const partResponse = {
characterId: response.characterId,
characterName: response.characterName,
content: partContent,
isVoice: partIsVoice
};
memberMessages[memberName].push(partResponse);
currentRoundResponses.push(partResponse);
}
}
// 成功,跳出重试循环
break;
} catch (err) {
lastError = err;
console.error(`[可乐] ${member.name} 的 AI 调用失败 (第${attempt}次):`, err.message);
if (attempt < MAX_RETRIES) {
// 等待一段时间后重试(递增延迟)
const delay = 1000 * attempt; // 1秒, 2秒, 3秒...
console.log(`[可乐] ${member.name} 将在 ${delay}ms 后重试...`);
await sleep(delay);
} else {
// 5次都失败了记录错误但继续处理其他角色
console.error(`[可乐] ${member.name} 的 AI 调用失败,已重试${MAX_RETRIES}次:`, lastError.message);
}
}
}
}
// 交错合并各角色的消息:按 speakingOrder 轮询每次取1条实现自然交错
const results = [];
const memberNames = speakingOrder.filter(name => memberMessages[name]?.length > 0);
const memberIndexes = {};
memberNames.forEach(name => { memberIndexes[name] = 0; });
while (true) {
let pushedAny = false;
for (const name of memberNames) {
const msgs = memberMessages[name] || [];
const idx = memberIndexes[name] || 0;
if (idx >= msgs.length) continue;
// 每次只取1条实现更自然的交错对话
results.push(msgs[idx]);
memberIndexes[name] = idx + 1;
pushedAny = true;
}
if (!pushedAny) break;
}
// 如果没有任何响应,返回一个默认响应
if (results.length === 0 && members.length > 0) {
results.push({
characterId: members[0].id,
characterName: members[0].name,
content: '...',
isVoice: false
});
}
return results;
}
// 使用群聊API决定发言顺序
async function determineSpeakingOrder(groupChat, members, userMessage, silentCharacters = []) {
const settings = getSettings();
// 使用群聊API来决定发言顺序
const apiUrl = settings.groupApiUrl || settings.apiUrl;
const apiKey = settings.groupApiKey || settings.apiKey;
const apiModel = settings.groupSelectedModel || settings.selectedModel;
// 如果没有配置群聊API让所有角色都参与保底角色优先其他随机排序
if (!apiUrl || !apiModel) {
const order = [];
// 保底角色优先
silentCharacters.forEach(name => {
if (members.find(m => m.name === name)) {
order.push(name);
}
});
// 其他角色按群成员顺序加入(避免随机打乱)
const otherMembers = members.filter(m => !silentCharacters.includes(m.name));
otherMembers.forEach(m => order.push(m.name));
return order.length > 0 ? order : [members[0]?.name].filter(Boolean);
}
try {
const memberNames = members.map(m => m.name).join('、');
const silentInfo = silentCharacters.length > 0
? `\n注意:${silentCharacters.join('、')} 已经沉默很久了,应该优先让他们发言。`
: '';
const orderPrompt = `你是一个群聊发言顺序调度器。
当前群聊成员有:${memberNames}
用户刚才说:${userMessage}${silentInfo}
请根据对话内容判断:
1. 哪些角色应该回复这条消息(不需要所有人都回复)
2. 他们的发言顺序应该是什么(避免抢话,让对话自然流畅)
请直接返回应该发言的角色名列表用逗号分隔例如角色A,角色B
不需要解释,只返回角色名列表。如果没人需要回复,返回第一个角色名。`;
const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(chatUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: apiModel,
messages: [{ role: 'user', content: orderPrompt }],
temperature: 1,
max_tokens: 8196
})
});
if (!response.ok) {
throw new Error('获取发言顺序失败');
}
const data = await response.json();
const orderText = data.choices?.[0]?.message?.content || '';
// 解析返回的角色名列表
const orderedNamesRaw = orderText
.split(/[,,、\n]/)
.map(name => name.trim())
.filter(name => members.find(m => m.name === name));
// 去重,防止重复调用同一角色
const orderedNames = [];
const seen = new Set();
orderedNamesRaw.forEach(name => {
if (!seen.has(name)) {
orderedNames.push(name);
seen.add(name);
}
});
if (orderedNames.length > 0) {
// 确保保底角色在列表中(按 silentCharacters 原顺序插到最前)
const silentToAdd = silentCharacters.filter(name =>
members.find(m => m.name === name) && !seen.has(name)
);
return [...silentToAdd, ...orderedNames];
}
} catch (err) {
console.error('[可乐] 获取发言顺序失败:', err);
}
// 如果调用失败,使用默认顺序
const defaultOrder = [];
silentCharacters.forEach(name => {
if (members.find(m => m.name === name)) {
defaultOrder.push(name);
}
});
if (defaultOrder.length === 0) {
defaultOrder.push(members[0]?.name);
}
return defaultOrder.filter(Boolean);
}
// 计算沉默太久的角色连续4次用户发言没回复
function getSilentCharacters(groupChat, members) {
const chatHistory = groupChat.chatHistory || [];
const silentCharacters = [];
// 初始化每个成员的沉默计数
const silenceCounts = {};
members.forEach(m => {
silenceCounts[m.name] = 0;
});
// 从历史记录末尾往前数,统计每个角色的沉默次数
let userMessageCount = 0;
const respondedInSession = new Set();
for (let i = chatHistory.length - 1; i >= 0 && userMessageCount < 4; i--) {
const msg = chatHistory[i];
if (msg.role === 'user') {
userMessageCount++;
// 重置本轮已回复的角色记录
respondedInSession.clear();
} else if (msg.role === 'assistant' && msg.characterName) {
respondedInSession.add(msg.characterName);
}
}
// 再次遍历,统计连续沉默
userMessageCount = 0;
for (let i = chatHistory.length - 1; i >= 0 && userMessageCount < 4; i--) {
const msg = chatHistory[i];
if (msg.role === 'user') {
userMessageCount++;
// 检查这次用户发言之后有没有角色回复
const respondersAfterThis = new Set();
for (let j = i + 1; j < chatHistory.length; j++) {
const nextMsg = chatHistory[j];
if (nextMsg.role === 'user') break;
if (nextMsg.role === 'assistant' && nextMsg.characterName) {
respondersAfterThis.add(nextMsg.characterName);
}
}
// 没回复的角色沉默计数+1
members.forEach(m => {
if (!respondersAfterThis.has(m.name)) {
silenceCounts[m.name]++;
}
});
}
}
// 找出沉默>=4次的角色
members.forEach(m => {
if (silenceCounts[m.name] >= 4) {
silentCharacters.push(m.name);
}
});
return silentCharacters;
}
// AI间对话提示词
function buildAIDialoguePrompt(groupChat, members, lastResponses) {
const lastSpeakers = lastResponses.map(r => r.characterName).join('、');
const lastMessages = lastResponses.map(r => `[${r.characterName}]: ${r.content}`).join('\n');
return `【群聊互动(继续聊天)】
刚才 ${lastSpeakers || '群友'} 的发言:
${lastMessages}
请你作为“你自己”system 中指定的角色)对上面的内容做出自然回应。
规则:
1. 只输出你自己的台词,不要替其他角色发言,不要复述或生成其他角色的台词
2. 不要添加任何角色名前缀(不要写“[角色名]:”/“名字:”)
3. 回复尽量简短自然1-2 句);如要连发 1-2 条,用 ||| 分隔
4. 如果觉得无需回应,可以返回空`;
}
// 自动同步群成员的角色卡世界书到 selectedLorebooks
async function syncGroupMembersLorebooks(members, settings) {
if (!settings.selectedLorebooks) settings.selectedLorebooks = [];
let hasChanges = false;
for (const member of members) {
const rawData = member.rawData || {};
const charData = rawData.data || rawData;
const characterBook = charData.character_book;
if (!characterBook || !characterBook.entries || characterBook.entries.length === 0) {
continue;
}
const charName = charData.name || member.name;
const lorebookName = characterBook.name || charName;
// 查找该角色对应的世界书(避免仅按 name 命中全局世界书)
let existingIdx = -1;
if (member.id) {
existingIdx = settings.selectedLorebooks.findIndex(lb => lb?.fromCharacter === true && lb.characterId === member.id);
}
if (existingIdx < 0) {
existingIdx = settings.selectedLorebooks.findIndex(lb => lb?.fromCharacter === true && lb.characterName === charName);
}
if (existingIdx < 0) {
existingIdx = settings.selectedLorebooks.findIndex(lb => lb?.fromCharacter === true && lb.name === lorebookName);
}
if (existingIdx >= 0) {
// 更新已有的世界书的角色关联信息(如果缺失)
const existing = settings.selectedLorebooks[existingIdx];
if (!existing.characterName || !existing.characterId) {
existing.characterName = charName;
existing.characterId = member.id;
existing.fromCharacter = true;
hasChanges = true;
console.log('[可乐] 更新世界书角色关联:', lorebookName, '-> 角色:', charName, 'ID:', member.id);
}
} else {
// 添加新的世界书
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')}`;
const entries = characterBook.entries.map((entry, idx) => ({
uid: entry.id ?? idx,
keys: entry.keys || [],
keysecondary: entry.secondary_keys || [],
content: entry.content || '',
comment: entry.comment || entry.name || '',
enabled: isLorebookEntryEnabled(entry),
constant: entry.constant ?? false,
selective: entry.selective ?? true,
order: entry.insertion_order ?? entry.order ?? 100,
position: entry.position ?? 0,
depth: entry.depth ?? 4
}));
settings.selectedLorebooks.push({
name: lorebookName,
entries,
addedTime: timeStr,
enabled: true,
fromCharacter: true,
characterName: charName,
characterId: member.id
});
hasChanges = true;
console.log('[可乐] 自动同步角色世界书:', lorebookName, '角色:', charName, 'ID:', member.id, '条目数:', entries.length);
}
}
if (hasChanges) {
requestSave();
}
}
// 发送群聊消息
export async function sendGroupMessage(messageText, isMultipleMessages = false, isVoice = false) {
console.log('[可乐] ===== sendGroupMessage 被调用 =====', { messageText, isMultipleMessages, isVoice, currentGroupChatIndex });
if (currentGroupChatIndex < 0) {
console.log('[可乐] currentGroupChatIndex < 0退出');
return;
}
const settings = getSettings();
const groupChat = settings.groupChats?.[currentGroupChatIndex];
if (!groupChat) return;
// 获取成员信息(限制:最多 3 个独立 AI + 用户)
const { memberIds } = enforceGroupChatMemberLimit(groupChat);
const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean);
if (members.length === 0) {
showToast('群聊成员不存在', '⚠️');
return;
}
// 群聊必须全部使用独立 API
const invalidMembers = members.filter(m => !m.useCustomApi || !m.customApiUrl || !m.customModel);
if (invalidMembers.length > 0) {
const names = invalidMembers.map(m => m?.name || '未知').join('、');
showToast(`以下成员未配置独立API${names}`, '⚠️');
return;
}
// 自动同步群成员的角色卡世界书到 selectedLorebooks
await syncGroupMembersLorebooks(members, settings);
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
const msgTimestamp = Date.now();
// 清空输入框
const input = document.getElementById('wechat-input');
if (input) input.value = '';
// 更新发送按钮状态
window.updateSendButtonState?.();
// 处理多条消息
let messagesToSend = [];
if (isMultipleMessages && Array.isArray(messageText)) {
messagesToSend = messageText.filter(m => m.trim());
} else if (typeof messageText === 'string' && messageText.trim()) {
messagesToSend = [messageText.trim()];
}
if (messagesToSend.length === 0) return;
// 逐条显示用户消息
for (let i = 0; i < messagesToSend.length; i++) {
const msg = messagesToSend[i];
appendGroupMessage('user', msg, null, null, isVoice);
if (i < messagesToSend.length - 1) {
await sleep(300);
}
}
// 添加到历史
for (const msg of messagesToSend) {
groupChat.chatHistory.push({
role: 'user',
content: msg,
time: timeStr,
timestamp: msgTimestamp,
isVoice: isVoice
});
}
// 立即保存,确保用户消息不会丢失
saveNow();
// 显示打字指示器
showGroupTypingIndicator(members[0]?.name, members[0]?.id);
try {
// 计算沉默太久的角色
const silentCharacters = getSilentCharacters(groupChat, members);
// 调用 AI
const combinedUserMessage = messagesToSend.join('\n');
const combinedMessage = isVoice
? `[用户发送了语音消息,内容是:${combinedUserMessage}]`
: combinedUserMessage;
let responses = await callGroupAI(groupChat, members, combinedMessage, silentCharacters);
hideGroupTypingIndicator();
// 逐条显示 AI 回复每条消息之间间隔约1秒
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
// 替换占位符
const displayContent = replaceMessagePlaceholders(resp.content);
// 调试日志:检查显示时的角色信息
console.log('[可乐] 显示消息:', {
index: i,
characterName: resp.characterName,
characterId: resp.characterId,
content: displayContent?.substring(0, 30)
});
// 显示 typing 指示器并等待约1秒模拟打字延迟
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400); // 0.8-1.2秒
hideGroupTypingIndicator();
groupChat.chatHistory.push({
role: 'assistant',
content: displayContent,
characterId: resp.characterId,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now(),
isVoice: resp.isVoice,
isSticker: resp.isSticker
});
appendGroupMessage('assistant', displayContent, resp.characterName, resp.characterId, resp.isVoice, resp.isSticker);
}
// AI间对话最多3轮让角色之间互动
let dialogueRound = 0;
let lastResponses = responses;
const allRespondedNames = new Set(responses.map(r => r.characterName));
while (dialogueRound < 3 && lastResponses.length > 0 && members.length > 1) {
// 获取可以回应的角色(优先选择还没发言的,但也允许已发言的继续对话)
const lastSpeakerNames = new Set(lastResponses.map(r => r.characterName));
let otherMembers = members.filter(m => !lastSpeakerNames.has(m.name));
// 如果所有角色都已在本轮发言,则允许任何角色继续对话(除了刚刚发言的)
if (otherMembers.length === 0 && dialogueRound < 2) {
// 从已发言的角色中随机选择一些继续对话
const previousSpeakers = members.filter(m =>
allRespondedNames.has(m.name) && !lastSpeakerNames.has(m.name)
);
if (previousSpeakers.length > 0) {
otherMembers = previousSpeakers;
}
}
if (otherMembers.length === 0) break;
// 等待一下再发起AI间对话
await sleep(800 + Math.random() * 400);
// 构建AI间对话提示
const dialoguePrompt = buildAIDialoguePrompt(groupChat, members, lastResponses);
// 随机决定是否产生AI间对话80%概率产生)
if (Math.random() > 0.8) {
dialogueRound++;
continue;
}
showGroupTypingIndicator(otherMembers[0]?.name, otherMembers[0]?.id);
try {
const dialogueResponses = await callGroupAI(groupChat, members, dialoguePrompt, []);
hideGroupTypingIndicator();
// 过滤掉空回复
const validResponses = dialogueResponses.filter(r => r.content && r.content.trim());
if (validResponses.length === 0) {
dialogueRound++;
break;
}
// 显示AI间对话回复逐条显示每条间隔约1秒
for (let i = 0; i < validResponses.length; i++) {
const resp = validResponses[i];
// 替换占位符
const displayContent = replaceMessagePlaceholders(resp.content);
// 显示 typing 指示器并等待约1秒
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400); // 0.8-1.2秒
hideGroupTypingIndicator();
groupChat.chatHistory.push({
role: 'assistant',
content: displayContent,
characterId: resp.characterId,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now(),
isVoice: resp.isVoice
});
appendGroupMessage('assistant', displayContent, resp.characterName, resp.characterId, resp.isVoice, resp.isSticker);
}
lastResponses = validResponses;
// 记录所有已发言的角色
validResponses.forEach(r => allRespondedNames.add(r.characterName));
dialogueRound++;
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] AI间对话失败:', err);
break;
}
}
// 更新最后消息
const allResponses = groupChat.chatHistory.filter(m => m.role === 'assistant');
if (allResponses.length > 0) {
const lastResp = allResponses[allResponses.length - 1];
const lastContent = replaceMessagePlaceholders(lastResp.content);
groupChat.lastMessage = `[${lastResp.characterName}]: ${lastResp.isSticker ? '[表情]' : (lastResp.isVoice ? '[语音消息]' : lastContent)}`;
}
groupChat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
checkGroupSummaryReminder(groupChat);
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊 AI 调用失败:', err);
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false);
requestSave();
}
}
// 判断当前是否在群聊
export function isInGroupChat() {
const messagesContainer = document.getElementById('wechat-chat-messages');
const result = messagesContainer?.dataset.isGroup === 'true';
console.log('[可乐] isInGroupChat 检查:', {
containerExists: !!messagesContainer,
isGroupValue: messagesContainer?.dataset?.isGroup,
result
});
return result;
}
// 获取当前群聊索引
export function getCurrentGroupIndex() {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer?.dataset.isGroup === 'true') {
const index = parseInt(messagesContainer.dataset.groupIndex);
return isNaN(index) ? -1 : index;
}
return -1;
}
// 发送群聊表情贴纸消息
export async function sendGroupStickerMessage(stickerUrl, description = '') {
const groupIndex = getCurrentGroupIndex();
if (groupIndex < 0) return;
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
if (!groupChat) return;
if (!Array.isArray(groupChat.chatHistory)) {
groupChat.chatHistory = [];
}
// 获取成员信息
const { memberIds } = enforceGroupChatMemberLimit(groupChat);
const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean);
if (members.length === 0) {
showToast('群聊成员不存在', '⚠️');
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')}`;
const msgTimestamp = Date.now();
// 保存到聊天历史
groupChat.chatHistory.push({
role: 'user',
content: stickerUrl,
time: timeStr,
timestamp: msgTimestamp,
isSticker: true,
stickerDescription: description || ''
});
// 更新最后消息
groupChat.lastMessage = '[表情]';
groupChat.lastMessageTime = msgTimestamp;
// 立即保存,确保用户消息不会丢失
saveNow();
// 显示消息
appendGroupStickerMessage('user', stickerUrl);
// 显示打字指示器
showGroupTypingIndicator(members[0]?.name, members[0]?.id);
try {
// 自动同步群成员的角色卡世界书
await syncGroupMembersLorebooks(members, settings);
// 计算沉默太久的角色
const silentCharacters = getSilentCharacters(groupChat, members);
// 调用 AI - 传递表情描述让 AI 理解
const aiPrompt = description
? `[用户发送了一个表情包:${description}]`
: '[用户发送了一个表情包]';
const responses = await callGroupAI(groupChat, members, aiPrompt, silentCharacters);
hideGroupTypingIndicator();
// 逐条显示 AI 回复
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
const displayContent = replaceMessagePlaceholders(resp.content);
// 显示 typing 指示器并等待
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400);
hideGroupTypingIndicator();
groupChat.chatHistory.push({
role: 'assistant',
content: displayContent,
characterId: resp.characterId,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now(),
isVoice: resp.isVoice,
isSticker: resp.isSticker
});
appendGroupMessage('assistant', displayContent, resp.characterName, resp.characterId, resp.isVoice, resp.isSticker);
}
// 更新最后消息
const allResponses = groupChat.chatHistory.filter(m => m.role === 'assistant');
if (allResponses.length > 0) {
const lastResp = allResponses[allResponses.length - 1];
const lastContent = replaceMessagePlaceholders(lastResp.content);
groupChat.lastMessage = `[${lastResp.characterName}]: ${lastResp.isSticker ? '[表情]' : (lastResp.isVoice ? '[语音消息]' : lastContent)}`;
}
groupChat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
checkGroupSummaryReminder(groupChat);
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊表情消息 AI 调用失败:', err);
requestSave();
refreshChatList();
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false);
}
}
// 添加群聊表情消息到界面
function appendGroupStickerMessage(role, stickerUrl, characterName = null, characterId = null) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
let avatarContent;
if (role === 'user') {
avatarContent = getUserAvatarHTML();
} else {
const settings = getSettings();
const contact = settings.contacts?.find(c => c.id === characterId);
const firstChar = characterName?.charAt(0) || '?';
avatarContent = contact?.avatar
? `<img src="${contact.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content">
<div class="wechat-sticker-bubble">
<img src="${stickerUrl}" alt="表情" class="wechat-sticker-img">
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
const imgEl = messageDiv.querySelector('img.wechat-sticker-img');
if (imgEl) {
bindImageLoadFallback(imgEl, {
errorAlt: '图片加载失败',
errorStyle: {
border: '2px dashed #ff4d4f',
padding: '10px',
background: 'rgba(255,77,79,0.1)'
},
onFail: (baseSrc) => {
console.error('[可乐] 群聊表情包图片加载失败:', {
src: imgEl.src?.substring(0, 80),
原始URL: (baseSrc || '').substring(0, 120),
完整URL: stickerUrl
});
}
});
}
}
// 发送群聊照片消息
export async function sendGroupPhotoMessage(description) {
const groupIndex = getCurrentGroupIndex();
if (groupIndex < 0) return;
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
if (!groupChat) return;
if (!Array.isArray(groupChat.chatHistory)) {
groupChat.chatHistory = [];
}
// 获取成员信息
const { memberIds } = enforceGroupChatMemberLimit(groupChat);
const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean);
if (members.length === 0) {
showToast('群聊成员不存在', '⚠️');
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')}`;
const msgTimestamp = Date.now();
// 保存到聊天历史(直接使用用户描述)
groupChat.chatHistory.push({
role: 'user',
content: description,
time: timeStr,
timestamp: msgTimestamp,
isPhoto: true
});
// 更新最后消息
groupChat.lastMessage = '[照片]';
groupChat.lastMessageTime = msgTimestamp;
// 立即保存,确保用户消息不会丢失
saveNow();
// 显示消息
appendGroupPhotoMessage('user', description);
// 显示打字指示器
showGroupTypingIndicator(members[0]?.name, members[0]?.id);
try {
// 计算沉默太久的角色
const silentCharacters = getSilentCharacters(groupChat, members);
// 调用 AI
const responses = await callGroupAI(groupChat, members, `[用户发送了一张照片,图片描述:${description}]`, silentCharacters);
hideGroupTypingIndicator();
// 逐条显示 AI 回复
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
const displayContent = replaceMessagePlaceholders(resp.content);
// 显示 typing 指示器并等待
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400);
hideGroupTypingIndicator();
groupChat.chatHistory.push({
role: 'assistant',
content: displayContent,
characterId: resp.characterId,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now(),
isVoice: resp.isVoice,
isSticker: resp.isSticker
});
appendGroupMessage('assistant', displayContent, resp.characterName, resp.characterId, resp.isVoice, resp.isSticker);
}
// 更新最后消息
const allResponses = groupChat.chatHistory.filter(m => m.role === 'assistant');
if (allResponses.length > 0) {
const lastResp = allResponses[allResponses.length - 1];
const lastContent = replaceMessagePlaceholders(lastResp.content);
groupChat.lastMessage = `[${lastResp.characterName}]: ${lastResp.isSticker ? '[表情]' : (lastResp.isVoice ? '[语音消息]' : lastContent)}`;
}
groupChat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
checkGroupSummaryReminder(groupChat);
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊照片消息 AI 调用失败:', err);
requestSave();
refreshChatList();
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null, false);
}
}
// 添加群聊照片消息到界面
function appendGroupPhotoMessage(role, description, characterName = null, characterId = null) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
const photoId = 'photo_' + Math.random().toString(36).substring(2, 9);
let avatarContent;
if (role === 'user') {
avatarContent = getUserAvatarHTML();
} else {
const settings = getSettings();
const contact = settings.contacts?.find(c => c.id === characterId);
const firstChar = characterName?.charAt(0) || '?';
avatarContent = contact?.avatar
? `<img src="${contact.avatar}" alt="" onerror="this.style.display='none';this.parentElement.innerHTML='${firstChar}'">`
: firstChar;
}
messageDiv.innerHTML = `
<div class="wechat-message-avatar">${avatarContent}</div>
<div class="wechat-message-content">
<div class="wechat-photo-bubble" data-photo-id="${photoId}">
<div class="wechat-photo-content" id="${photoId}-content">${escapeHtml(description)}</div>
<div class="wechat-photo-blur" id="${photoId}-blur">
<div class="wechat-photo-icon">
<svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h12l-1.5 16H7.5L6 4z"/><path d="M6 4c0-1 1-2 6-2s6 1 6 2"/><path d="M9 8h6"/><circle cx="15" cy="6" r="0.5" fill="currentColor"/><circle cx="11" cy="7" r="0.5" fill="currentColor"/></svg>
</div>
<span class="wechat-photo-hint">点击查看</span>
</div>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 绑定点击事件toggle切换蒙层
const photoBubble = messageDiv.querySelector('.wechat-photo-bubble');
photoBubble?.addEventListener('click', () => {
const blurEl = document.getElementById(`${photoId}-blur`);
if (blurEl) {
blurEl.classList.toggle('hidden');
}
});
}
// 批量发送混合消息一次性发完再调用AI
// messages: [{ type: 'text'|'voice'|'sticker'|'photo', content: string }]
export async function sendGroupBatchMessages(messages) {
const groupIndex = getCurrentGroupIndex();
if (groupIndex < 0) return;
if (!messages || messages.length === 0) return;
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
if (!groupChat) return;
if (!Array.isArray(groupChat.chatHistory)) {
groupChat.chatHistory = [];
}
// 获取成员信息
const { memberIds } = enforceGroupChatMemberLimit(groupChat);
const members = memberIds.map(id => settings.contacts.find(c => c.id === id)).filter(Boolean);
if (members.length === 0) {
showToast('群聊成员不存在', '⚠️');
return;
}
// 群聊必须全部使用独立 API
const invalidMembers = members.filter(m => !m.useCustomApi || !m.customApiUrl || !m.customModel);
if (invalidMembers.length > 0) {
const names = invalidMembers.map(m => m?.name || '未知').join('、');
showToast(`以下成员未配置独立API${names}`, '⚠️');
return;
}
// 自动同步群成员的角色卡世界书
await syncGroupMembersLorebooks(members, settings);
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
const msgTimestamp = Date.now();
// 清空输入框
const input = document.getElementById('wechat-input');
if (input) input.value = '';
window.updateSendButtonState?.();
// 构建AI提示词的描述
const promptParts = [];
// 第一步显示所有用户消息不调用AI
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const content = msg.content?.trim();
if (!content) continue;
if (msg.type === 'sticker') {
// 表情消息
groupChat.chatHistory.push({
role: 'user',
content: content,
time: timeStr,
timestamp: msgTimestamp,
isSticker: true
});
appendGroupStickerMessage('user', content);
promptParts.push('[用户发送了一个表情包]');
} else if (msg.type === 'photo') {
// 照片消息
groupChat.chatHistory.push({
role: 'user',
content: content,
time: timeStr,
timestamp: msgTimestamp,
isPhoto: true
});
appendGroupPhotoMessage('user', content);
promptParts.push(`[用户发送了一张照片,描述:${content}]`);
} else if (msg.type === 'voice') {
// 语音消息
groupChat.chatHistory.push({
role: 'user',
content: content,
time: timeStr,
timestamp: msgTimestamp,
isVoice: true
});
appendGroupMessage('user', content, null, null, true);
promptParts.push(`[用户发送了语音消息:${content}]`);
} else {
// 文字消息
groupChat.chatHistory.push({
role: 'user',
content: content,
time: timeStr,
timestamp: msgTimestamp
});
appendGroupMessage('user', content, null, null, false);
promptParts.push(content);
}
// 消息之间的间隔
if (i < messages.length - 1) {
await sleep(200);
}
}
// 更新最后消息
const lastMsg = messages[messages.length - 1];
if (lastMsg.type === 'sticker') {
groupChat.lastMessage = '[表情]';
} else if (lastMsg.type === 'photo') {
groupChat.lastMessage = '[照片]';
} else if (lastMsg.type === 'voice') {
groupChat.lastMessage = '[语音消息]';
} else {
// 检查内容是否包含 <meme> 标签
const content = lastMsg.content || '';
if (content.includes('<meme>')) {
groupChat.lastMessage = '[图片]';
} else {
groupChat.lastMessage = content;
}
}
groupChat.lastMessageTime = msgTimestamp;
// 立即保存,确保用户消息不会丢失
saveNow();
// 第二步调用AI一次性
showGroupTypingIndicator(members[0]?.name, members[0]?.id);
try {
// 计算沉默太久的角色
const silentCharacters = getSilentCharacters(groupChat, members);
const combinedPrompt = promptParts.join('\n');
const responses = await callGroupAI(groupChat, members, combinedPrompt, silentCharacters);
hideGroupTypingIndicator();
// 逐条显示 AI 回复
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
const displayContent = replaceMessagePlaceholders(resp.content);
// 显示 typing 指示器并等待
showGroupTypingIndicator(resp.characterName, resp.characterId);
await sleep(800 + Math.random() * 400);
hideGroupTypingIndicator();
groupChat.chatHistory.push({
role: 'assistant',
content: displayContent,
characterId: resp.characterId,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now(),
isVoice: resp.isVoice,
isSticker: resp.isSticker
});
appendGroupMessage('assistant', displayContent, resp.characterName, resp.characterId, resp.isVoice, resp.isSticker);
}
// 更新最后消息
const allResponses = groupChat.chatHistory.filter(m => m.role === 'assistant');
if (allResponses.length > 0) {
const lastResp = allResponses[allResponses.length - 1];
const lastContent = replaceMessagePlaceholders(lastResp.content);
groupChat.lastMessage = `[${lastResp.characterName}]: ${lastResp.isSticker ? '[表情]' : (lastResp.isVoice ? '[语音消息]' : lastContent)}`;
}
groupChat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
checkGroupSummaryReminder(groupChat);
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊批量消息 AI 调用失败:', err);
requestSave();
refreshChatList();
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null);
}
}