Compare commits

...

3 Commits

Author SHA1 Message Date
Cola-Echo
262611c736 Add files via upload 2026-01-02 16:10:57 +08:00
Cola-Echo
4a097a613b Add files via upload 2026-01-02 02:48:58 +08:00
Cola-Echo
8595a7c48d Add files via upload 2026-01-02 02:26:21 +08:00
14 changed files with 3863 additions and 58 deletions

11
ai.js
View File

@@ -258,6 +258,17 @@ export async function testApiConnection() {
} }
} }
// 测试指定 API 连接(接受参数)
export async function testConnection(apiUrl, apiKey, model) {
if (!apiUrl) {
throw new Error('请先配置 API 地址');
}
// 尝试获取模型列表来验证连接
await fetchModelListFromApi(apiUrl, apiKey);
return true;
}
// 获取模型列表 // 获取模型列表
export async function fetchModelList() { export async function fetchModelList() {
const config = getApiConfig(); const config = getApiConfig();

27
chat.js
View File

@@ -753,6 +753,11 @@ export function openChat(contactIndex) {
// 加载联系人的聊天背景 // 加载联系人的聊天背景
loadContactBackground(contactIndex); loadContactBackground(contactIndex);
// 隐藏群聊专属菜单项,显示单聊专属菜单项
document.getElementById('wechat-menu-invite-member')?.classList.add('hidden');
document.getElementById('wechat-menu-block')?.classList.remove('hidden');
document.getElementById('wechat-menu-moments')?.classList.remove('hidden');
} }
// 通过联系人ID打开聊天 // 通过联系人ID打开聊天
@@ -1471,7 +1476,8 @@ function getRealMsgIndexForVoice(container, msgElement) {
let visualMsgCount = 1; let visualMsgCount = 1;
const content = msg.content || ''; const content = msg.content || '';
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic; const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
if (!isSpecial && content.indexOf('|||') >= 0) { // 只有 assistant 消息才会被 ||| 分割显示
if (msg.role === 'assistant' && !isSpecial && content.indexOf('|||') >= 0) {
const parts = content.split('|||').map(p => p.trim()).filter(p => p); const parts = content.split('|||').map(p => p.trim()).filter(p => p);
visualMsgCount = parts.length || 1; visualMsgCount = parts.length || 1;
} }
@@ -2017,14 +2023,14 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
} }
} }
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/); const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) { if (voiceMatch) {
aiMsg = voiceMatch[1]; aiMsg = voiceMatch[1];
aiIsVoice = true; aiIsVoice = true;
} }
// 解析AI照片格式 [照片:描述] // 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/); const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) { if (photoMatch) {
aiMsg = photoMatch[1]; aiMsg = photoMatch[1];
aiIsPhoto = true; aiIsPhoto = true;
@@ -2488,6 +2494,13 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
// 尝试触发语音/视频通话(随机触发+保底机制) // 尝试触发语音/视频通话(随机触发+保底机制)
tryTriggerCallAfterChat(contactIndex); tryTriggerCallAfterChat(contactIndex);
// 检查其他联系人是否要主动发消息
import('./proactive-message.js').then(m => {
m.checkOtherContactsProactive(contact.id);
}).catch(err => {
console.error('[可乐] 主动消息检查失败:', err);
});
} catch (err) { } catch (err) {
hideTypingIndicator(); hideTypingIndicator();
console.error('[可乐] AI 调用失败:', err); console.error('[可乐] AI 调用失败:', err);
@@ -2590,14 +2603,14 @@ export async function sendStickerMessage(stickerUrl, description = '') {
if (!aiMsg.trim()) continue; if (!aiMsg.trim()) continue;
} }
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/); const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) { if (voiceMatch) {
aiMsg = voiceMatch[1]; aiMsg = voiceMatch[1];
aiIsVoice = true; aiIsVoice = true;
} }
// 解析AI照片格式 [照片:描述] // 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/); const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) { if (photoMatch) {
aiMsg = photoMatch[1]; aiMsg = photoMatch[1];
aiIsPhoto = true; aiIsPhoto = true;
@@ -3037,14 +3050,14 @@ export async function sendPhotoMessage(description) {
if (!aiMsg.trim()) continue; if (!aiMsg.trim()) continue;
} }
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/); const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) { if (voiceMatch) {
aiMsg = voiceMatch[1]; aiMsg = voiceMatch[1];
aiIsVoice = true; aiIsVoice = true;
} }
// 解析AI照片格式 [照片:描述] // 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/); const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) { if (photoMatch) {
aiMsg = photoMatch[1]; aiMsg = photoMatch[1];
aiIsPhoto = true; aiIsPhoto = true;

View File

@@ -143,6 +143,148 @@ function deleteGroupLorebooks(group, settings) {
} }
} }
// 删除多人群聊
export function deleteMultiPersonChat(mpIndex) {
const settings = getSettings();
const multiPersonChats = settings.multiPersonChats || [];
const mpChat = multiPersonChats[mpIndex];
if (!mpChat) return;
if (confirm(`确定要删除「${mpChat.name || '多人群聊'}」吗?`)) {
multiPersonChats.splice(mpIndex, 1);
requestSave();
refreshContactsList();
// 同时刷新聊天列表
import('./ui.js').then(m => m.refreshChatList());
showToast('多人群聊已删除');
}
}
// 当前正在编辑的多人群聊索引
let currentEditingMpIndex = -1;
let pendingMpAvatar = null; // 待保存的头像
// 打开多人群聊配置弹窗
export function openMpApiSettings(mpIndex) {
const settings = getSettings();
const mpChat = settings.multiPersonChats?.[mpIndex];
if (!mpChat) return;
currentEditingMpIndex = mpIndex;
pendingMpAvatar = null;
// 填充头像预览
const avatarPreview = document.getElementById('wechat-mp-avatar-preview');
if (avatarPreview) {
if (mpChat.avatar) {
avatarPreview.innerHTML = `<img src="${mpChat.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`;
} else {
avatarPreview.innerHTML = '群';
}
}
// 填充群名
const nameInput = document.getElementById('wechat-mp-name-input');
if (nameInput) {
nameInput.value = mpChat.name || '群聊';
}
// 填充API配置
const useCustomApi = mpChat.useCustomApi || false;
const customSwitch = document.getElementById('wechat-mp-use-custom-api');
if (customSwitch) {
customSwitch.classList.toggle('on', useCustomApi);
}
const apiConfigSection = document.getElementById('wechat-mp-api-config');
const globalTip = document.getElementById('wechat-mp-global-tip');
if (apiConfigSection) apiConfigSection.classList.toggle('hidden', !useCustomApi);
if (globalTip) globalTip.classList.toggle('hidden', useCustomApi);
document.getElementById('wechat-mp-api-url').value = mpChat.customApiUrl || '';
document.getElementById('wechat-mp-api-key').value = mpChat.customApiKey || '';
// 模型选择
const modelSelect = document.getElementById('wechat-mp-model-select');
if (modelSelect) {
modelSelect.innerHTML = '<option value="">--请选择模型--</option>';
if (mpChat.customModel) {
modelSelect.innerHTML += `<option value="${mpChat.customModel}" selected>${mpChat.customModel}</option>`;
}
}
// 显示弹窗
document.getElementById('wechat-mp-api-modal')?.classList.remove('hidden');
}
// 保存多人群聊配置
export function saveMpApiSettings() {
if (currentEditingMpIndex < 0) return;
const settings = getSettings();
const mpChat = settings.multiPersonChats?.[currentEditingMpIndex];
if (!mpChat) return;
// 保存群名
const nameInput = document.getElementById('wechat-mp-name-input');
if (nameInput) {
mpChat.name = nameInput.value.trim() || '群聊';
}
// 保存头像
if (pendingMpAvatar) {
mpChat.avatar = pendingMpAvatar;
}
// 保存API配置
const useCustomApi = document.getElementById('wechat-mp-use-custom-api')?.classList.contains('on') || false;
mpChat.useCustomApi = useCustomApi;
if (useCustomApi) {
mpChat.customApiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim() || '';
mpChat.customApiKey = document.getElementById('wechat-mp-api-key')?.value?.trim() || '';
// 获取模型值
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const isManualMode = inputWrapper?.style.display === 'flex';
mpChat.customModel = isManualMode
? (document.getElementById('wechat-mp-model-input')?.value?.trim() || '')
: (document.getElementById('wechat-mp-model-select')?.value?.trim() || '');
}
requestSave();
showToast('设置已保存');
refreshContactsList();
// 同时刷新聊天列表
import('./ui.js').then(m => m.refreshChatList());
// 关闭弹窗
document.getElementById('wechat-mp-api-modal')?.classList.add('hidden');
currentEditingMpIndex = -1;
pendingMpAvatar = null;
}
// 处理多人群聊头像选择
export function handleMpAvatarChange(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
pendingMpAvatar = e.target.result;
const avatarPreview = document.getElementById('wechat-mp-avatar-preview');
if (avatarPreview) {
avatarPreview.innerHTML = `<img src="${pendingMpAvatar}" style="width: 100%; height: 100%; object-fit: cover;">`;
}
};
reader.readAsDataURL(file);
}
// 关闭多人群聊API配置弹窗
export function closeMpApiSettings() {
document.getElementById('wechat-mp-api-modal')?.classList.add('hidden');
currentEditingMpIndex = -1;
}
// 更换角色头像(在设置弹窗中使用) // 更换角色头像(在设置弹窗中使用)
export function changeContactAvatar(contactIndex) { export function changeContactAvatar(contactIndex) {
pendingAvatarContactIndex = contactIndex; pendingAvatarContactIndex = contactIndex;
@@ -351,6 +493,37 @@ export function bindContactsEvents() {
}); });
}); });
// 多人群聊卡片点击进入聊天
import('./multi-person-chat.js').then(mpModule => {
document.querySelectorAll('.wechat-mp-card .wechat-mp-card-content').forEach(card => {
card.addEventListener('click', function(e) {
// 如果点击的是头像,不进入聊天(由头像自己的事件处理)
if (e.target.closest('.wechat-mp-avatar')) return;
const cardEl = this.closest('.wechat-mp-card');
const mpIndex = parseInt(cardEl.dataset.mpIndex);
mpModule.openMultiPersonChat(mpIndex);
});
});
});
// 多人群聊头像点击配置API
document.querySelectorAll('.wechat-mp-avatar').forEach(avatar => {
avatar.addEventListener('click', function(e) {
e.stopPropagation();
const mpIndex = parseInt(this.dataset.mpIndex);
openMpApiSettings(mpIndex);
});
});
// 多人群聊删除按钮
document.querySelectorAll('.wechat-mp-delete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const mpIndex = parseInt(this.dataset.mpIndex);
deleteMultiPersonChat(mpIndex);
});
});
// 头像事件绑定(长按删除 + 单击打开设置) // 头像事件绑定(长按删除 + 单击打开设置)
document.querySelectorAll('.wechat-card-avatar').forEach(avatar => { document.querySelectorAll('.wechat-card-avatar').forEach(avatar => {
let pressTimer = null; let pressTimer = null;

View File

@@ -635,6 +635,11 @@ export function openGroupChat(groupIndex) {
// 加载群聊背景 // 加载群聊背景
loadGroupBackground(groupIndex); loadGroupBackground(groupIndex);
// 显示群聊专属菜单项,隐藏单聊专属菜单项
document.getElementById('wechat-menu-invite-member')?.classList.remove('hidden');
document.getElementById('wechat-menu-block')?.classList.add('hidden');
document.getElementById('wechat-menu-moments')?.classList.add('hidden');
} }
// 渲染群聊历史 // 渲染群聊历史
@@ -2404,6 +2409,11 @@ export async function sendGroupMessage(messageText, isMultipleMessages = false,
refreshChatList(); refreshChatList();
checkGroupSummaryReminder(groupChat); checkGroupSummaryReminder(groupChat);
// 检测群聊中的负面情绪,可能触发私聊
// 传递群聊上下文最近40条消息
const groupContext = getGroupChatHistoryForApi(groupChat.chatHistory, 40);
detectGroupEmotionAndTriggerPrivate(responses, members, groupContext);
} catch (err) { } catch (err) {
hideGroupTypingIndicator(); hideGroupTypingIndicator();
console.error('[可乐] 群聊 AI 调用失败:', err); console.error('[可乐] 群聊 AI 调用失败:', err);
@@ -2932,3 +2942,194 @@ export async function sendGroupBatchMessages(messages) {
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null); appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null);
} }
} }
/**
* 显示邀请成员弹窗
*/
export function showInviteMemberModal() {
const settings = getSettings();
const groupChat = settings.groupChats?.[currentGroupChatIndex];
if (!groupChat) {
showToast('请先打开一个群聊', '⚠️');
return;
}
const currentMemberIds = groupChat.memberIds || [];
// 检查是否已满
if (currentMemberIds.length >= GROUP_CHAT_MAX_AI_MEMBERS) {
showToast(`群聊已满(最多${GROUP_CHAT_MAX_AI_MEMBERS}人)`, '⚠️');
return;
}
// 获取可邀请的联系人不在群里的、配置了独立API的
const availableContacts = settings.contacts.filter(c =>
!currentMemberIds.includes(c.id) &&
c.useCustomApi &&
c.customApiUrl &&
c.customModel
);
if (availableContacts.length === 0) {
showToast('没有可邀请的联系人\n需配置独立API', '⚠️');
return;
}
// 获取手机容器
const phoneContainer = document.querySelector('.wechat-phone');
if (!phoneContainer) return;
// 构建弹窗HTML
const modal = document.createElement('div');
modal.className = 'wechat-modal';
modal.id = 'wechat-invite-member-modal';
modal.innerHTML = `
<div class="wechat-modal-content" style="max-height: 70vh; overflow-y: auto;">
<div class="wechat-modal-title">邀请成员</div>
<div class="wechat-modal-body">
<div style="margin-bottom: 12px; color: #888; font-size: 12px;">
当前 ${currentMemberIds.length}/${GROUP_CHAT_MAX_AI_MEMBERS}
</div>
<div id="wechat-invite-contact-list" style="max-height: 300px; overflow-y: auto;">
${availableContacts.map(c => `
<div class="wechat-invite-contact-item" data-contact-id="${c.id}"
style="display: flex; align-items: center; padding: 10px; cursor: pointer; border-bottom: 1px solid #eee; transition: background 0.2s;">
<div style="width: 40px; height: 40px; border-radius: 4px; background: #07c160; color: white;
display: flex; align-items: center; justify-content: center; margin-right: 10px; overflow: hidden;">
${c.avatar ? `<img src="${c.avatar}" style="width: 100%; height: 100%; object-fit: cover;">` : escapeHtml(c.name.charAt(0))}
</div>
<span>${escapeHtml(c.name)}</span>
</div>
`).join('')}
</div>
</div>
<div class="wechat-modal-actions">
<button class="wechat-btn" id="wechat-invite-cancel">取消</button>
</div>
</div>
`;
phoneContainer.appendChild(modal);
// 添加hover效果
modal.querySelectorAll('.wechat-invite-contact-item').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = '#f5f5f5';
});
item.addEventListener('mouseleave', () => {
item.style.background = '';
});
// 点击联系人邀请
item.addEventListener('click', () => {
const contactId = item.dataset.contactId;
addMemberToGroup(currentGroupChatIndex, contactId);
modal.remove();
});
});
// 取消按钮
modal.querySelector('#wechat-invite-cancel')?.addEventListener('click', () => {
modal.remove();
});
// 点击遮罩关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
}
/**
* 添加成员到群聊
*/
export function addMemberToGroup(groupIndex, contactId) {
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
const contact = settings.contacts.find(c => c.id === contactId);
if (!groupChat || !contact) return;
// 检查是否已满
if (groupChat.memberIds.length >= GROUP_CHAT_MAX_AI_MEMBERS) {
showToast('群聊已满', '⚠️');
return;
}
// 检查是否已存在
if (groupChat.memberIds.includes(contactId)) {
showToast('该成员已在群聊中', 'info');
return;
}
// 添加成员
groupChat.memberIds.push(contactId);
// 添加系统消息
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')}`;
groupChat.chatHistory.push({
role: 'system',
content: `${contact.name} 加入了群聊`,
isSystemNotice: true,
time: timeStr,
timestamp: Date.now()
});
// 更新群名(如果是默认名)
if (!groupChat.customName) {
const memberNames = groupChat.memberIds
.map(id => settings.contacts.find(c => c.id === id)?.name)
.filter(Boolean);
groupChat.name = memberNames.join('、');
}
requestSave();
// 刷新界面
openGroupChat(groupIndex);
refreshChatList();
showToast(`${contact.name} 已加入群聊`);
console.log(`[可乐] ${contact.name} 加入群聊:`, groupChat.name);
}
/**
* 检测群聊中的负面情绪,可能触发私聊
* @param {Array} responses - AI回复数组
* @param {Array} members - 群成员数组
* @param {Array} groupContext - 群聊上下文最近40条消息
*/
function detectGroupEmotionAndTriggerPrivate(responses, members, groupContext = []) {
if (!responses || responses.length === 0) return;
// 负面情绪关键词
const NEGATIVE_KEYWORDS = [
'生气', '讨厌', '烦', '不理你', '哼', '算了', '随便',
'滚', '走开', '别说了', '不想理', '烦死了', '气死',
'委屈', '难过', '伤心', '失望'
];
for (const resp of responses) {
const content = resp.content || '';
const characterId = resp.characterId;
if (!characterId) continue;
// 检测负面情绪
const hasNegativeEmotion = NEGATIVE_KEYWORDS.some(kw => content.includes(kw));
if (hasNegativeEmotion) {
console.log(`[可乐] 群聊检测到 ${resp.characterName} 的负面情绪:`, content.substring(0, 30));
// 触发私聊(延迟执行,有概率触发)
// 传递群聊上下文让私聊时AI知道群里发生了什么
import('./proactive-message.js').then(m => {
m.triggerProactiveFromGroup(characterId, 'negative', groupContext);
}).catch(err => {
console.error('[可乐] 群聊情绪触发私聊失败:', err);
});
}
}
}

285
main.js
View File

@@ -12,7 +12,7 @@ import { showPage, refreshChatList, updateMePageInfo, getUserPersonaFromST, upda
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { ICON_SUCCESS, ICON_INFO } from './icons.js'; import { ICON_SUCCESS, ICON_INFO } from './icons.js';
import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex } from './contacts.js'; import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex, closeMpApiSettings, saveMpApiSettings, handleMpAvatarChange } from './contacts.js';
import { openChatByContactId, setCurrentChatIndex, sendMessage, showRecalledMessages, currentChatIndex, openChat, updateBlockMenuText, startBlockedAIMessages, stopBlockedAIMessages, showBlockedMessages } from './chat.js'; import { openChatByContactId, setCurrentChatIndex, sendMessage, showRecalledMessages, currentChatIndex, openChat, updateBlockMenuText, startBlockedAIMessages, stopBlockedAIMessages, showBlockedMessages } from './chat.js';
import { refreshFavoritesList, showLorebookModal, syncCharacterBookToTavern, showAddLorebookPanel, showAddPersonaPanel } from './favorites.js'; import { refreshFavoritesList, showLorebookModal, syncCharacterBookToTavern, showAddLorebookPanel, showAddPersonaPanel } from './favorites.js';
import { executeSummary, rollbackSummary, refreshSummaryChatList, selectAllSummaryChats, recoverFromTavernWorldbook } from './summary.js'; import { executeSummary, rollbackSummary, refreshSummaryChatList, selectAllSummaryChats, recoverFromTavernWorldbook } from './summary.js';
@@ -23,6 +23,7 @@ import { extractCharacterFromPNG, extractCharacterFromJSON, importCharacterToST
import { setupPhoneAutoCentering, setupPhoneDrag, centerPhoneInViewport } from './phone.js'; import { setupPhoneAutoCentering, setupPhoneDrag, centerPhoneInViewport } from './phone.js';
import { showGroupCreateModal, closeGroupCreateModal, createGroupChat, sendGroupMessage, isInGroupChat, setCurrentGroupChatIndex, getCurrentGroupIndex, openGroupChat } from './group-chat.js'; import { showGroupCreateModal, closeGroupCreateModal, createGroupChat, sendGroupMessage, isInGroupChat, setCurrentGroupChatIndex, getCurrentGroupIndex, openGroupChat } from './group-chat.js';
import { isInMultiPersonChat, sendMultiPersonMessage, setCurrentMultiPersonChatIndex } from './multi-person-chat.js';
import { toggleDarkMode, refreshContextTags } from './settings-ui.js'; import { toggleDarkMode, refreshContextTags } from './settings-ui.js';
import { initFuncPanel, toggleFuncPanel, hideFuncPanel, showExpandVoice, closeExpandPanel, sendExpandContent } from './chat-func-panel.js'; import { initFuncPanel, toggleFuncPanel, hideFuncPanel, showExpandVoice, closeExpandPanel, sendExpandContent } from './chat-func-panel.js';
import { initEmojiPanel, toggleEmojiPanel, hideEmojiPanel } from './emoji-panel.js'; import { initEmojiPanel, toggleEmojiPanel, hideEmojiPanel } from './emoji-panel.js';
@@ -39,6 +40,7 @@ import { initCropper } from './cropper.js';
import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js'; import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js';
import { testSttApi, testTtsApi } from './voice-api.js'; import { testSttApi, testTtsApi } from './voice-api.js';
import { getVoiceRecordingsByContact, deleteVoiceRecording, playVoiceRecording, getAllVoiceRecordingsGroupedByContact, deleteVoiceRecordingsByContact } from './audio-storage.js'; import { getVoiceRecordingsByContact, deleteVoiceRecording, playVoiceRecording, getAllVoiceRecordingsGroupedByContact, deleteVoiceRecordingsByContact } from './audio-storage.js';
import { initMultiCharImport, openMultiImportModal, getMultiCharImportModalHtml, getCharSelectModalHtml, getCharOtherEditModalHtml } from './multi-char-import.js';
// ========== 历史记录功能 ========== // ========== 历史记录功能 ==========
let currentHistoryTab = 'listen'; let currentHistoryTab = 'listen';
@@ -71,7 +73,7 @@ function closeHistoryPage() {
currentHistoryContactIndex = -1; currentHistoryContactIndex = -1;
} }
function deleteHistoryRecord(tabType, index) { function deleteHistoryRecord(tabType, index, isRealVoice = false) {
const settings = getSettings(); const settings = getSettings();
const contact = settings.contacts?.[currentHistoryContactIndex]; const contact = settings.contacts?.[currentHistoryContactIndex];
if (!contact) return; if (!contact) return;
@@ -80,8 +82,32 @@ function deleteHistoryRecord(tabType, index) {
if (contact.listenHistory && contact.listenHistory[index]) { if (contact.listenHistory && contact.listenHistory[index]) {
contact.listenHistory.splice(index, 1); contact.listenHistory.splice(index, 1);
} }
} else if (tabType === 'voice' || tabType === 'video') { } else if (tabType === 'voice') {
// 从 callHistory 中找到并删除对应类型的记录 if (isRealVoice) {
// 删除实时语音记录
if (contact.realVoiceCallHistory && contact.realVoiceCallHistory.length > 0) {
// 找到实时语音记录在合并数组中的索引对应的原始索引
const realVoiceRecords = contact.realVoiceCallHistory;
const callHistory = contact.callHistory || [];
const voiceRecords = callHistory.filter(r => r.type === 'voice');
// index 是在合并数组中的位置,需要计算在 realVoiceCallHistory 中的实际位置
const realVoiceIndex = index - voiceRecords.length;
if (realVoiceIndex >= 0 && realVoiceIndex < realVoiceRecords.length) {
contact.realVoiceCallHistory.splice(realVoiceIndex, 1);
}
}
} else {
// 删除普通语音通话记录
const callHistory = contact.callHistory || [];
const typeRecords = callHistory.filter(r => r.type === 'voice');
if (typeRecords[index]) {
const originalIndex = callHistory.indexOf(typeRecords[index]);
if (originalIndex >= 0) {
contact.callHistory.splice(originalIndex, 1);
}
}
}
} else if (tabType === 'video') {
const callHistory = contact.callHistory || []; const callHistory = contact.callHistory || [];
const typeRecords = callHistory.filter(r => r.type === tabType); const typeRecords = callHistory.filter(r => r.type === tabType);
if (typeRecords[index]) { if (typeRecords[index]) {
@@ -151,8 +177,17 @@ function renderHistoryContent(contact, tabType) {
let records = []; let records = [];
if (tabType === 'listen') { if (tabType === 'listen') {
records = contact.listenHistory || []; records = contact.listenHistory || [];
} else if (tabType === 'voice') {
// 语音通话:合并普通语音通话和实时语音通话
const callHistory = contact.callHistory || [];
const voiceRecords = callHistory.filter(r => r.type === 'voice');
const realVoiceRecords = (contact.realVoiceCallHistory || []).map(r => ({
...r,
isRealVoice: true // 标记为实时语音
}));
records = [...voiceRecords, ...realVoiceRecords];
} else { } else {
// 从 callHistory 中筛选 voice 或 video // 从 callHistory 中筛选 video
const callHistory = contact.callHistory || []; const callHistory = contact.callHistory || [];
records = callHistory.filter(r => r.type === tabType); records = callHistory.filter(r => r.type === tabType);
} }
@@ -179,12 +214,13 @@ function renderHistoryContent(contact, tabType) {
const duration = record.duration || ''; const duration = record.duration || '';
const messages = record.messages || []; const messages = record.messages || [];
const originalIndex = records.indexOf(record); const originalIndex = records.indexOf(record);
const isRealVoice = record.isRealVoice ? 'true' : 'false';
html += `<div class="wechat-history-card" data-tab="${tabType}" data-index="${originalIndex}">`; html += `<div class="wechat-history-card" data-tab="${tabType}" data-index="${originalIndex}" data-real-voice="${isRealVoice}">`;
html += `<div class="wechat-history-card-header">`; html += `<div class="wechat-history-card-header">`;
html += `<span class="wechat-history-card-time">${escapeHtml(time)}</span>`; html += `<span class="wechat-history-card-time">${escapeHtml(time)}${record.isRealVoice ? ' <span style="color: #07c160; font-size: 12px;">[实时语音]</span>' : ''}</span>`;
html += `<div class="wechat-history-card-actions">`; html += `<div class="wechat-history-card-actions">`;
html += `<button class="wechat-history-delete-btn" data-tab="${tabType}" data-index="${originalIndex}" title="删除">×</button>`; html += `<button class="wechat-history-delete-btn" data-tab="${tabType}" data-index="${originalIndex}" data-real-voice="${isRealVoice}" title="删除">×</button>`;
if (duration) { if (duration) {
html += `<span class="wechat-history-card-duration">${escapeHtml(duration)}</span>`; html += `<span class="wechat-history-card-duration">${escapeHtml(duration)}</span>`;
} }
@@ -224,7 +260,8 @@ function renderHistoryContent(contact, tabType) {
e.stopPropagation(); e.stopPropagation();
const tab = btn.dataset.tab; const tab = btn.dataset.tab;
const index = parseInt(btn.dataset.index); const index = parseInt(btn.dataset.index);
deleteHistoryRecord(tab, index); const isRealVoice = btn.dataset.realVoice === 'true';
deleteHistoryRecord(tab, index, isRealVoice);
}); });
}); });
} }
@@ -986,11 +1023,14 @@ function bindEvents() {
document.getElementById('wechat-chat-back-btn')?.addEventListener('click', () => { document.getElementById('wechat-chat-back-btn')?.addEventListener('click', () => {
setCurrentChatIndex(-1); setCurrentChatIndex(-1);
setCurrentGroupChatIndex(-1); setCurrentGroupChatIndex(-1);
// 清除群聊标记 setCurrentMultiPersonChatIndex(-1);
// 清除群聊和多人群聊标记
const messagesContainer = document.getElementById('wechat-chat-messages'); const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer) { if (messagesContainer) {
messagesContainer.dataset.isGroup = 'false'; messagesContainer.dataset.isGroup = 'false';
messagesContainer.dataset.groupIndex = '-1'; messagesContainer.dataset.groupIndex = '-1';
messagesContainer.dataset.isMultiPerson = 'false';
messagesContainer.dataset.multiPersonIndex = '-1';
// 清除背景 // 清除背景
messagesContainer.style.backgroundImage = ''; messagesContainer.style.backgroundImage = '';
} }
@@ -1026,6 +1066,12 @@ function bindEvents() {
document.getElementById('wechat-recalled-panel')?.classList.add('hidden'); document.getElementById('wechat-recalled-panel')?.classList.add('hidden');
}); });
// 邀请成员(群聊)
document.getElementById('wechat-menu-invite-member')?.addEventListener('click', () => {
document.getElementById('wechat-chat-menu')?.classList.add('hidden');
import('./group-chat.js').then(m => m.showInviteMemberModal());
});
// 查看TA的朋友圈 // 查看TA的朋友圈
document.getElementById('wechat-menu-moments')?.addEventListener('click', () => { document.getElementById('wechat-menu-moments')?.addEventListener('click', () => {
document.getElementById('wechat-chat-menu')?.classList.add('hidden'); document.getElementById('wechat-chat-menu')?.classList.add('hidden');
@@ -1218,6 +1264,11 @@ function bindEvents() {
this.value = ''; this.value = '';
}); });
// 导入多人卡
document.getElementById('wechat-import-multi-card')?.addEventListener('click', () => {
openMultiImportModal();
});
// 深色模式切换 // 深色模式切换
document.getElementById('wechat-dark-toggle')?.addEventListener('click', toggleDarkMode); document.getElementById('wechat-dark-toggle')?.addEventListener('click', toggleDarkMode);
@@ -1522,6 +1573,186 @@ function bindEvents() {
} }
}); });
// ===== 多人群聊配置弹窗事件 =====
// 关闭按钮
document.getElementById('wechat-mp-api-close')?.addEventListener('click', closeMpApiSettings);
// 保存按钮
document.getElementById('wechat-mp-api-save')?.addEventListener('click', saveMpApiSettings);
// 更换头像按钮
document.getElementById('wechat-mp-change-avatar')?.addEventListener('click', () => {
document.getElementById('wechat-mp-avatar-file')?.click();
});
// 头像预览点击也可以更换
document.getElementById('wechat-mp-avatar-preview')?.addEventListener('click', () => {
document.getElementById('wechat-mp-avatar-file')?.click();
});
// 头像文件选择
document.getElementById('wechat-mp-avatar-file')?.addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (file) {
handleMpAvatarChange(file);
}
e.target.value = ''; // 清空以便重复选择同一文件
});
// 独立API开关
document.getElementById('wechat-mp-use-custom-api')?.addEventListener('click', () => {
const toggle = document.getElementById('wechat-mp-use-custom-api');
const apiConfigDiv = document.getElementById('wechat-mp-api-config');
const globalTip = document.getElementById('wechat-mp-global-tip');
toggle?.classList.toggle('on');
const isOn = toggle?.classList.contains('on');
if (apiConfigDiv) {
if (isOn) {
apiConfigDiv.classList.remove('hidden');
apiConfigDiv.style.display = 'flex';
} else {
apiConfigDiv.classList.add('hidden');
apiConfigDiv.style.display = 'none';
}
}
if (globalTip) {
globalTip.classList.toggle('hidden', isOn);
}
});
// 多人群聊API获取模型按钮
document.getElementById('wechat-mp-fetch-model')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim();
const apiKey = document.getElementById('wechat-mp-api-key')?.value?.trim();
const modelSelect = document.getElementById('wechat-mp-model-select');
const fetchBtn = document.getElementById('wechat-mp-fetch-model');
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('获取失败: ' + err.message, '⚠️');
} finally {
fetchBtn.textContent = '获取';
fetchBtn.disabled = false;
}
});
// 多人群聊API手动输入按钮
document.getElementById('wechat-mp-model-manual')?.addEventListener('click', () => {
const selectWrapper = document.getElementById('wechat-mp-model-select-wrapper');
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const modelSelect = document.getElementById('wechat-mp-model-select');
const modelInput = document.getElementById('wechat-mp-model-input');
if (modelSelect?.value) {
modelInput.value = modelSelect.value;
}
selectWrapper.style.display = 'none';
inputWrapper.style.display = 'flex';
modelInput?.focus();
});
// 多人群聊API返回按钮
document.getElementById('wechat-mp-model-back')?.addEventListener('click', () => {
const selectWrapper = document.getElementById('wechat-mp-model-select-wrapper');
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const modelSelect = document.getElementById('wechat-mp-model-select');
const modelInput = document.getElementById('wechat-mp-model-input');
const inputValue = modelInput?.value?.trim();
if (inputValue && modelSelect) {
const existingOption = Array.from(modelSelect.options).find(opt => opt.value === inputValue);
if (existingOption) {
modelSelect.value = inputValue;
} else {
const newOption = document.createElement('option');
newOption.value = inputValue;
newOption.textContent = inputValue;
modelSelect.appendChild(newOption);
modelSelect.value = inputValue;
}
}
selectWrapper.style.display = 'flex';
inputWrapper.style.display = 'none';
});
// 多人群聊API测试连接按钮
document.getElementById('wechat-mp-test-api')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim();
const apiKey = document.getElementById('wechat-mp-api-key')?.value?.trim();
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const isManualMode = inputWrapper?.style.display === 'flex';
const model = isManualMode
? document.getElementById('wechat-mp-model-input')?.value?.trim()
: document.getElementById('wechat-mp-model-select')?.value?.trim();
const testBtn = document.getElementById('wechat-mp-test-api');
if (!apiUrl) {
showToast('请先填写API地址', 'info');
return;
}
if (!model) {
showToast('请先填写或选择模型', 'info');
return;
}
testBtn.textContent = '测试中...';
testBtn.disabled = true;
try {
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: model,
messages: [{ role: 'user', content: '请回复"连接成功"' }],
max_tokens: 50
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`${response.status}: ${errorText.substring(0, 100)}`);
}
const data = await response.json();
const reply = data.choices?.[0]?.message?.content || '';
showToast(`连接成功!回复: ${reply.substring(0, 20)}...`, 'success');
} catch (err) {
console.error('[可乐] 测试连接失败:', err);
showToast('❌ 连接失败: ' + err.message, '⚠️');
} finally {
testBtn.textContent = '测试连接';
testBtn.disabled = false;
}
});
// ===== 群聊设置事件 ===== // ===== 群聊设置事件 =====
// 群聊提示词注入开关 // 群聊提示词注入开关
document.getElementById('wechat-group-inject-toggle')?.addEventListener('click', () => { document.getElementById('wechat-group-inject-toggle')?.addEventListener('click', () => {
@@ -1584,10 +1815,15 @@ function bindEvents() {
text: text.substring(0, 20), text: text.substring(0, 20),
isGroup: messagesContainer?.dataset?.isGroup, isGroup: messagesContainer?.dataset?.isGroup,
groupIndex: messagesContainer?.dataset?.groupIndex, groupIndex: messagesContainer?.dataset?.groupIndex,
isInGroupChatResult: isInGroupChat() isMultiPerson: messagesContainer?.dataset?.isMultiPerson,
isInGroupChatResult: isInGroupChat(),
isInMultiPersonChatResult: isInMultiPersonChat()
}); });
if (isInGroupChat()) { if (isInMultiPersonChat()) {
console.log('[可乐] 调用 sendMultiPersonMessage');
sendMultiPersonMessage(text);
} else if (isInGroupChat()) {
console.log('[可乐] 调用 sendGroupMessage'); console.log('[可乐] 调用 sendGroupMessage');
sendGroupMessage(text); sendGroupMessage(text);
} else { } else {
@@ -1605,7 +1841,9 @@ function bindEvents() {
const text = chatInput?.value?.trim(); const text = chatInput?.value?.trim();
if (text) { if (text) {
// 有文字时发送消息 // 有文字时发送消息
if (isInGroupChat()) { if (isInMultiPersonChat()) {
sendMultiPersonMessage(text);
} else if (isInGroupChat()) {
sendGroupMessage(text); sendGroupMessage(text);
} else { } else {
sendMessage(text); sendMessage(text);
@@ -1639,6 +1877,7 @@ function bindEvents() {
initGiftEvents(); initGiftEvents();
initCropper(); initCropper();
initHistoryEvents(); initHistoryEvents();
initMultiCharImport();
// 展开面板 // 展开面板
document.getElementById('wechat-expand-close')?.addEventListener('click', closeExpandPanel); document.getElementById('wechat-expand-close')?.addEventListener('click', closeExpandPanel);
@@ -1682,7 +1921,7 @@ function bindEvents() {
}); });
}); });
// 聊天列表项点击(支持单聊群聊) // 聊天列表项点击(支持单聊、群聊和多人群聊)
document.getElementById('wechat-chat-list')?.addEventListener('click', (e) => { document.getElementById('wechat-chat-list')?.addEventListener('click', (e) => {
const chatItem = e.target.closest('.wechat-chat-item'); const chatItem = e.target.closest('.wechat-chat-item');
if (!chatItem) return; if (!chatItem) return;
@@ -1693,6 +1932,12 @@ function bindEvents() {
if (!isNaN(groupIndex)) { if (!isNaN(groupIndex)) {
import('./group-chat.js').then(m => m.openGroupChat(groupIndex)); import('./group-chat.js').then(m => m.openGroupChat(groupIndex));
} }
} else if (chatItem.classList.contains('wechat-chat-item-mp')) {
// 多人群聊
const mpIndex = parseInt(chatItem.dataset.mpIndex);
if (!isNaN(mpIndex)) {
import('./multi-person-chat.js').then(m => m.openMultiPersonChat(mpIndex));
}
} else { } else {
// 单聊 // 单聊
const contactId = chatItem.dataset.contactId; const contactId = chatItem.dataset.contactId;
@@ -2029,6 +2274,13 @@ function bindEvents() {
return; return;
} }
if (service === 'multi-char-table') {
// 切换角色表格区域的显示/隐藏
const section = document.getElementById('wechat-char-tables-section');
section?.classList.toggle('hidden');
return;
}
const label = item.querySelector('span')?.textContent || '该'; const label = item.querySelector('span')?.textContent || '该';
showToast(`"${label}" 功能开发中...`, 'info'); showToast(`"${label}" 功能开发中...`, 'info');
}); });
@@ -2631,13 +2883,18 @@ function bindEvents() {
} }
function init() { function init() {
console.log('[可乐] init() 开始');
loadSettings(); loadSettings();
console.log('[可乐] loadSettings 调用完成,开始 getSettings');
const settings = getSettings(); const settings = getSettings();
console.log('[可乐] getSettings 完成,开始 seedDefaultUserPersonaFromST');
if (seedDefaultUserPersonaFromST(settings)) { if (seedDefaultUserPersonaFromST(settings)) {
requestSave(); requestSave();
} }
console.log('[可乐] seedDefaultUserPersonaFromST 完成,开始 generatePhoneHTML');
const phoneHTML = generatePhoneHTML(); const phoneHTML = generatePhoneHTML();
console.log('[可乐] generatePhoneHTML 完成');
document.body.insertAdjacentHTML('beforeend', phoneHTML); document.body.insertAdjacentHTML('beforeend', phoneHTML);
setupPhoneAutoCentering(); setupPhoneAutoCentering();

View File

@@ -7,7 +7,7 @@ import { requestSave } from './save-manager.js';
import { currentChatIndex, openChat, showTypingIndicator, hideTypingIndicator, appendMessage } from './chat.js'; import { currentChatIndex, openChat, showTypingIndicator, hideTypingIndicator, appendMessage } from './chat.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { getContext } from '../../../extensions.js'; import { getContext } from '../../../extensions.js';
import { formatQuoteDate } from './utils.js'; import { formatQuoteDate, sleep } from './utils.js';
import { isInGroupChat, getCurrentGroupIndex, openGroupChat } from './group-chat.js'; import { isInGroupChat, getCurrentGroupIndex, openGroupChat } from './group-chat.js';
// 当前显示菜单的消息索引 // 当前显示菜单的消息索引
@@ -118,12 +118,24 @@ export function showMessageMenu(msgElement, msgIndex, event) {
msg = contact?.chatHistory?.[msgIndex]; msg = contact?.chatHistory?.[msgIndex];
} }
// 优先从历史记录判断,其次从元素属性判断(处理分割显示的消息) // 从元素或其父元素获取 role 属性
let isUserMessage = msg?.role === 'user'; let roleAttr = msgElement?.dataset?.msgRole;
if (msg === undefined) { if (!roleAttr) {
// 如果找不到消息记录,尝试从元素属性获取 // 尝试从元素获取(气泡元素在 .wechat-message 内部)
const roleAttr = msgElement?.dataset?.msgRole || msgElement?.closest?.('[data-msg-role]')?.dataset?.msgRole; const parentMsg = msgElement?.closest?.('.wechat-message') || msgElement?.parentElement?.closest?.('.wechat-message');
isUserMessage = roleAttr === 'user'; roleAttr = parentMsg?.dataset?.msgRole;
}
let isUserMessage = roleAttr === 'user';
// 如果元素属性不存在,回退到历史记录判断
if (!roleAttr && msg) {
isUserMessage = msg.role === 'user';
}
// 最后检查通过元素类名判断self 类表示用户消息)
if (!roleAttr && !msg) {
const parentMsg = msgElement?.closest?.('.wechat-message');
isUserMessage = parentMsg?.classList?.contains('self') || false;
} }
// 检测是否是语音消息 // 检测是否是语音消息
@@ -470,6 +482,8 @@ async function regenerateMessage(msgIndex, contact) {
// 触发AI重新回复 // 触发AI重新回复
try { try {
// 等待 DOM 更新后再显示 typing 指示器
await new Promise(resolve => setTimeout(resolve, 50));
showTypingIndicator(contact); showTypingIndicator(contact);
const { callAI } = await import('./ai.js'); const { callAI } = await import('./ai.js');
@@ -490,12 +504,17 @@ async function regenerateMessage(msgIndex, contact) {
if (!finalMsg) continue; if (!finalMsg) continue;
let isVoice = false; let isVoice = false;
const voiceMatch = finalMsg.match(/^\[语音[:]\s*(.+?)\]$/); const voiceMatch = finalMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) { if (voiceMatch) {
finalMsg = voiceMatch[1]; finalMsg = voiceMatch[1];
isVoice = true; isVoice = true;
} }
// 每条消息都要有typing效果和2-2.5秒延迟(与普通回复一致)
showTypingIndicator(contact);
await sleep(2000 + Math.random() * 500);
hideTypingIndicator();
contact.chatHistory.push({ contact.chatHistory.push({
role: 'assistant', role: 'assistant',
content: finalMsg, content: finalMsg,
@@ -721,7 +740,8 @@ function getRealMsgIndex(container, msgElement) {
const content = msg.content || ''; const content = msg.content || '';
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic; const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
// 检查是否包含 ||| 或 <meme> 标签(这些会导致消息被分割显示) // 检查是否包含 ||| 或 <meme> 标签(这些会导致消息被分割显示)
if (!isSpecial && (content.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(content))) { // 注意:只有 assistant 消息才会被分割,用户消息不会分割
if (msg.role === 'assistant' && !isSpecial && (content.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(content))) {
// 使用 splitAIMessages 计算实际分割数量 // 使用 splitAIMessages 计算实际分割数量
const parts = splitAIMessages(content).filter(p => p && p.trim()); const parts = splitAIMessages(content).filter(p => p && p.trim());
visualMsgCount = parts.length || 1; visualMsgCount = parts.length || 1;

1501
multi-char-import.js Normal file

File diff suppressed because it is too large Load Diff

433
multi-person-chat.js Normal file
View File

@@ -0,0 +1,433 @@
/**
* 多人群聊模块
* 特点:无头像,名字+气泡,左对齐,世界观注入
*/
import { requestSave, saveNow } from './save-manager.js';
import { getSettings } from './config.js';
import { showToast } from './toast.js';
import { escapeHtml, sleep, formatMessageTime } from './utils.js';
import { refreshChatList } from './ui.js';
// 当前多人群聊索引
export let currentMultiPersonChatIndex = -1;
// 设置当前多人群聊索引
export function setCurrentMultiPersonChatIndex(index) {
currentMultiPersonChatIndex = index;
}
// 打开多人群聊
export function openMultiPersonChat(chatIndex) {
console.log('[可乐] openMultiPersonChat 被调用, chatIndex:', chatIndex);
const settings = getSettings();
const chat = settings.multiPersonChats?.[chatIndex];
if (!chat) return;
currentMultiPersonChatIndex = chatIndex;
// 确保 chatHistory 存在
if (!chat.chatHistory) chat.chatHistory = [];
// 隐藏主页,显示聊天页
document.getElementById('wechat-main-content')?.classList.add('hidden');
document.getElementById('wechat-chat-page')?.classList.remove('hidden');
document.getElementById('wechat-chat-title').textContent = `${chat.name}(${chat.members.length})`;
const messagesContainer = document.getElementById('wechat-chat-messages');
const chatHistory = chat.chatHistory;
if (chatHistory.length === 0) {
messagesContainer.innerHTML = '';
} else {
messagesContainer.innerHTML = renderMultiPersonChatHistory(chat, chatHistory);
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 标记当前是多人群聊模式
messagesContainer.dataset.isMultiPerson = 'true';
messagesContainer.dataset.multiPersonIndex = chatIndex;
messagesContainer.dataset.isGroup = 'false'; // 区别于普通群聊
}
// 渲染多人群聊历史记录
function renderMultiPersonChatHistory(chat, chatHistory) {
let html = '';
let lastTimestamp = 0;
const TIME_GAP_THRESHOLD = 5 * 60 * 1000;
chatHistory.forEach((msg, index) => {
const msgTimestamp = msg.timestamp || 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.role === 'user') {
// 用户消息:右对齐,有气泡
html += `
<div class="wechat-message self">
<div class="wechat-message-content">
<div class="wechat-message-bubble">${escapeHtml(msg.content)}</div>
</div>
</div>
`;
} else {
// 角色消息:无头像,名字+气泡,左对齐
const charName = msg.characterName || '未知';
html += `
<div class="wechat-message wechat-mp-message">
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(charName)}</div>
<div class="wechat-message-bubble">${escapeHtml(msg.content)}</div>
</div>
</div>
`;
}
});
return html;
}
// 追加多人群聊消息到界面
export function appendMultiPersonMessage(role, content, characterName = null) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
if (role === 'user') {
messageDiv.className = 'wechat-message self';
messageDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-message-bubble">${escapeHtml(content)}</div>
</div>
`;
} else {
messageDiv.className = 'wechat-message wechat-mp-message';
messageDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(characterName || '未知')}</div>
<div class="wechat-message-bubble">${escapeHtml(content)}</div>
</div>
`;
}
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 显示多人群聊打字指示器
export function showMultiPersonTypingIndicator(characterName) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
hideMultiPersonTypingIndicator();
const typingDiv = document.createElement('div');
typingDiv.className = 'wechat-message wechat-mp-message wechat-typing-wrapper';
typingDiv.id = 'wechat-mp-typing-indicator';
typingDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(characterName || '...')}</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 hideMultiPersonTypingIndicator() {
const indicator = document.getElementById('wechat-mp-typing-indicator');
if (indicator) indicator.remove();
}
// 构建多人群聊系统提示词
function buildMultiPersonSystemPrompt(chat, respondingMembers) {
const settings = getSettings();
let systemPrompt = '';
// 世界观(必读)
if (chat.worldView) {
systemPrompt += `【世界观设定】\n${chat.worldView}\n\n`;
}
// 参与角色信息
systemPrompt += `【参与角色】\n`;
systemPrompt += `这是一个包含 ${chat.members.length} 位角色的多人对话场景。\n\n`;
chat.members.forEach((member, idx) => {
systemPrompt += `角色 ${idx + 1}: ${member.name}\n`;
if (member.gender) systemPrompt += ` 性别: ${member.gender}\n`;
if (member.age) systemPrompt += ` 年龄: ${member.age}\n`;
if (member.description) systemPrompt += ` 描述: ${member.description}\n`;
systemPrompt += '\n';
});
// 本轮回复的角色
if (respondingMembers && respondingMembers.length > 0) {
systemPrompt += `【本轮发言角色】\n`;
systemPrompt += `本轮需要以下角色发言:${respondingMembers.map(m => m.name).join('、')}\n\n`;
}
// 回复格式说明
systemPrompt += `【回复格式】
你需要模拟多位角色的对话。请按以下格式回复:
[角色名]: 对话内容
如果有多个角色发言,请用 ||| 分隔每条消息。
示例:
[${chat.members[0]?.name || '角色A'}]: 你好啊 ||| [${chat.members[1]?.name || '角色B'}]: 嗨,好久不见
规则:
1. 每个角色保持自己的性格特点
2. 对话要自然流畅,像真实聊天
3. 每条消息简短自然1-3句话
4. 可以使用表情符号
5. 角色之间可以互相回应、互动
`;
return systemPrompt;
}
// 选择本轮回复的角色3-5人
function selectRespondingMembers(chat, userMessage) {
const members = chat.members || [];
const totalMembers = members.length;
// 根据群成员数量决定每轮回复人数
let respondCount;
if (totalMembers <= 5) {
// 5人及以下全部回复
respondCount = totalMembers;
} else if (totalMembers <= 10) {
// 6-10人每轮3-5人
respondCount = Math.min(5, Math.max(3, Math.floor(totalMembers * 0.5)));
} else {
// 10人以上每轮5人
respondCount = 5;
}
// 随机打乱成员顺序
const shuffled = [...members].sort(() => Math.random() - 0.5);
// 取前 respondCount 个
return shuffled.slice(0, respondCount);
}
// 调用多人群聊 AI
async function callMultiPersonAI(chat, userMessage, respondingMembers) {
const settings = getSettings();
// 使用全局 API 配置
const apiUrl = settings.apiUrl;
const apiKey = settings.apiKey;
const apiModel = settings.selectedModel;
if (!apiUrl || !apiModel) {
throw new Error('请先配置 AI 接口');
}
const systemPrompt = buildMultiPersonSystemPrompt(chat, respondingMembers);
const messages = [{ role: 'system', content: systemPrompt }];
// 添加历史消息
const chatHistory = chat.chatHistory || [];
const recentHistory = chatHistory.slice(-50);
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 });
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: 4096
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API 错误 (${response.status}): ${errorText.substring(0, 100)}`);
}
const data = await response.json();
const rawResponse = data.choices?.[0]?.message?.content || '';
return parseMultiPersonResponse(rawResponse, chat.members);
}
// 解析多人群聊 AI 回复
function parseMultiPersonResponse(response, members) {
const results = [];
// 按 ||| 分隔多条消息
const parts = response.split('|||').map(p => p.trim()).filter(p => p);
parts.forEach(part => {
// 匹配 [角色名]: 内容 格式
const match = part.match(/^\[(.+?)\][:]\s*(.+)$/s);
if (match) {
const charName = match[1].trim();
const content = match[2].trim();
// 查找对应的成员
const member = members.find(m => m.name === charName);
results.push({
characterName: member?.name || charName,
content: content
});
} else {
// 无法解析格式时,作为第一个角色的消息
if (members.length > 0 && part.trim()) {
results.push({
characterName: members[0].name,
content: part.trim()
});
}
}
});
return results;
}
// 发送多人群聊消息
export async function sendMultiPersonMessage(messageText) {
console.log('[可乐] sendMultiPersonMessage 被调用', { messageText, currentMultiPersonChatIndex });
if (currentMultiPersonChatIndex < 0) return;
const settings = getSettings();
const chat = settings.multiPersonChats?.[currentMultiPersonChatIndex];
if (!chat) 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();
// 清空输入框
const input = document.getElementById('wechat-input');
if (input) input.value = '';
window.updateSendButtonState?.();
// 显示用户消息
appendMultiPersonMessage('user', messageText);
// 确保 chatHistory 存在
if (!chat.chatHistory) chat.chatHistory = [];
// 添加到历史
chat.chatHistory.push({
role: 'user',
content: messageText,
time: timeStr,
timestamp: msgTimestamp
});
// 立即保存
saveNow();
// 选择本轮回复的角色
const respondingMembers = selectRespondingMembers(chat, messageText);
// 显示第一个角色的打字指示器
showMultiPersonTypingIndicator(respondingMembers[0]?.name);
try {
// 调用 AI
const responses = await callMultiPersonAI(chat, messageText, respondingMembers);
hideMultiPersonTypingIndicator();
// 逐条显示 AI 回复,带 typing 效果
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
// 显示 typing 指示器
showMultiPersonTypingIndicator(resp.characterName);
await sleep(600 + Math.random() * 400); // 0.6-1秒
hideMultiPersonTypingIndicator();
// 添加到历史
chat.chatHistory.push({
role: 'assistant',
content: resp.content,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now()
});
// 显示消息
appendMultiPersonMessage('assistant', resp.content, resp.characterName);
}
// 更新最后消息
if (responses.length > 0) {
const lastResp = responses[responses.length - 1];
chat.lastMessage = `[${lastResp.characterName}]: ${lastResp.content}`;
}
chat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
} catch (err) {
hideMultiPersonTypingIndicator();
console.error('[可乐] 多人群聊 AI 调用失败:', err);
appendMultiPersonMessage('assistant', `⚠️ ${err.message}`, '系统');
requestSave();
}
}
// 判断当前是否在多人群聊
export function isInMultiPersonChat() {
const messagesContainer = document.getElementById('wechat-chat-messages');
return messagesContainer?.dataset.isMultiPerson === 'true';
}
// 获取当前多人群聊索引
export function getCurrentMultiPersonIndex() {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer?.dataset.isMultiPerson === 'true') {
const index = parseInt(messagesContainer.dataset.multiPersonIndex);
return isNaN(index) ? -1 : index;
}
return -1;
}

View File

@@ -154,6 +154,13 @@ export function generatePhoneHTML() {
<div class="wechat-add-option-text">导入角色卡 (JSON)</div> <div class="wechat-add-option-text">导入角色卡 (JSON)</div>
<span class="wechat-add-option-arrow"></span> <span class="wechat-add-option-arrow"></span>
</div> </div>
<div class="wechat-add-option" id="wechat-import-multi-card">
<div class="wechat-add-option-icon">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
</div>
<div class="wechat-add-option-text">导入多人卡</div>
<span class="wechat-add-option-arrow"></span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -171,6 +178,14 @@ export function generatePhoneHTML() {
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
<span>撤回消息</span> <span>撤回消息</span>
</div> </div>
<div class="wechat-dropdown-item hidden" id="wechat-menu-invite-member">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M3 21v-2a4 4 0 014-4h4a4 4 0 014 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M19 8v6M16 11h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>邀请成员</span>
</div>
<div class="wechat-dropdown-item" id="wechat-menu-chat-bg"> <div class="wechat-dropdown-item" id="wechat-menu-chat-bg">
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/><path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg> <svg viewBox="0 0 24 24" width="18" height="18"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/><path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
<span>聊天背景</span> <span>聊天背景</span>
@@ -774,6 +789,14 @@ function generateServicePageHTML(settings) {
<div class="wechat-service-grid"> <div class="wechat-service-grid">
<div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div> <div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div>
<div class="wechat-service-item" data-service="voice-api"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音API</span></div> <div class="wechat-service-item" data-service="voice-api"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音API</span></div>
<div class="wechat-service-item" data-service="multi-char-table"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #3f51b5, #7986cb);"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>多人卡表格</span></div>
</div>
<!-- 角色表格容器(可折叠) -->
<div id="wechat-char-tables-section" class="hidden">
<div class="wechat-service-section-title" style="margin-top: 16px;">已解析的角色表格</div>
<div id="wechat-char-tables-container">
<!-- 角色表格由 JS 动态填充 -->
</div>
</div> </div>
</div> </div>
<div class="wechat-service-section"> <div class="wechat-service-section">
@@ -1014,7 +1037,7 @@ function generateModalsHTML(settings) {
<div id="wechat-group-contacts-list" style="max-height: 300px; overflow-y: auto; border: 1px solid var(--wechat-border); border-radius: 8px; padding: 8px;"></div> <div id="wechat-group-contacts-list" style="max-height: 300px; overflow-y: auto; border: 1px solid var(--wechat-border); border-radius: 8px; padding: 8px;"></div>
<div style="margin-top: 12px; text-align: center; color: var(--wechat-text-secondary); font-size: 13px;"> <div style="margin-top: 12px; text-align: center; color: var(--wechat-text-secondary); font-size: 13px;">
已选择 <span id="wechat-group-selected-count" style="color: var(--wechat-primary); font-weight: 500;">0</span> 人 已选择 <span id="wechat-group-selected-count" style="color: var(--wechat-green); font-weight: 500;">0</span> 人
</div> </div>
<div class="wechat-modal-actions" style="margin-top: 16px;"> <div class="wechat-modal-actions" style="margin-top: 16px;">
@@ -1039,6 +1062,71 @@ function generateModalsHTML(settings) {
</div> </div>
</div> </div>
</div> </div>
<!-- 多人群聊配置弹窗 -->
<div id="wechat-mp-api-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="position: relative; max-width: 380px; max-height: 85vh; overflow-y: auto;">
<button class="wechat-modal-close-x" id="wechat-mp-api-close">×</button>
<div class="wechat-modal-title">群聊设置</div>
<!-- 头像和群名编辑区 -->
<div class="wechat-settings-group" style="padding: 12px; background: var(--wechat-bg-secondary); border-radius: 8px; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div id="wechat-mp-avatar-preview" style="width: 60px; height: 60px; border-radius: 8px; overflow: hidden; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; color: #000; cursor: pointer; border: 1px solid #ddd;" title="点击更换头像">群</div>
<div style="flex: 1;">
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">群聊名称</span>
<input type="text" class="wechat-settings-input" id="wechat-mp-name-input" placeholder="群聊" style="width: 100%; box-sizing: border-box;">
</div>
</div>
<button class="wechat-btn wechat-btn-small" id="wechat-mp-change-avatar" style="width: 100%;">更换头像</button>
<input type="file" id="wechat-mp-avatar-file" accept="image/*" style="display: none;">
</div>
<!-- API配置区 -->
<div class="wechat-settings-group" style="padding: 12px; background: var(--wechat-bg-secondary); border-radius: 8px; margin-bottom: 12px;">
<div class="wechat-settings-item" style="margin-bottom: 12px;">
<span class="wechat-settings-label">使用独立API</span>
<div class="wechat-switch" id="wechat-mp-use-custom-api"></div>
</div>
<div id="wechat-mp-global-tip" style="font-size: 12px; color: var(--wechat-text-secondary);">
将使用全局 AI 配置
</div>
<div id="wechat-mp-api-config" class="hidden" style="display: flex; flex-direction: column; gap: 10px;">
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">API 地址</span>
<input type="text" class="wechat-settings-input" id="wechat-mp-api-url" placeholder="https://api.example.com/v1" style="width: 100%; box-sizing: border-box;">
</div>
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">API 密钥</span>
<input type="password" class="wechat-settings-input" id="wechat-mp-api-key" placeholder="sk-xxx" style="width: 100%; box-sizing: border-box;">
</div>
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">模型</span>
<div style="display: flex; gap: 8px;" id="wechat-mp-model-select-wrapper">
<select class="wechat-settings-input" id="wechat-mp-model-select" style="flex: 1; box-sizing: border-box;">
<option value="">---请选择模型---</option>
</select>
<button class="wechat-btn wechat-btn-small" id="wechat-mp-model-manual" style="white-space: nowrap;">手动</button>
<button class="wechat-btn wechat-btn-small wechat-btn-primary" id="wechat-mp-fetch-model" style="white-space: nowrap;">获取</button>
</div>
<div style="display: none; gap: 8px;" id="wechat-mp-model-input-wrapper">
<input type="text" class="wechat-settings-input" id="wechat-mp-model-input" placeholder="手动输入模型名称" style="flex: 1; box-sizing: border-box;">
<button class="wechat-btn wechat-btn-small" id="wechat-mp-model-back" style="white-space: nowrap;">返回</button>
</div>
</div>
<div style="display: flex; gap: 8px; margin-top: 4px;">
<button class="wechat-btn wechat-btn-small" id="wechat-mp-test-api" style="flex: 1;">测试连接</button>
</div>
</div>
</div>
<div class="wechat-modal-actions">
<button class="wechat-btn wechat-btn-primary" id="wechat-mp-api-save">保存</button>
</div>
</div>
</div>
`; `;
} }
@@ -1512,6 +1600,161 @@ function generateGiftPageHTML() {
</div> </div>
</div> </div>
<!-- 多人卡导入弹窗 -->
<div id="wechat-multi-import-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 420px; position: relative;">
<button class="wechat-modal-close-x" id="wechat-multi-import-close">×</button>
<div class="wechat-modal-header">
<span>导入多人卡</span>
</div>
<div class="wechat-modal-body">
<!-- AI 配置区 -->
<div class="wechat-settings-section">
<div class="wechat-settings-title">解析 AI 配置</div>
<!-- 使用独立API开关 -->
<div class="wechat-settings-row">
<span>使用独立API</span>
<div class="wechat-switch" id="wechat-multi-import-custom-api"></div>
</div>
<!-- API配置默认隐藏 -->
<div id="wechat-multi-import-api-config" class="hidden" style="margin-top: 12px;">
<div class="wechat-settings-item">
<label>API 地址</label>
<input type="text" class="wechat-settings-input"
id="wechat-multi-import-api-url"
placeholder="https://api.example.com/v1">
</div>
<div class="wechat-settings-item">
<label>API 密钥</label>
<input type="password" class="wechat-settings-input"
id="wechat-multi-import-api-key"
placeholder="sk-...">
</div>
<div class="wechat-settings-item">
<label>模型</label>
<div style="display: flex; gap: 8px;">
<div id="wechat-multi-import-model-select-wrapper" style="flex: 1; display: flex;">
<select class="wechat-settings-input wechat-settings-select"
id="wechat-multi-import-model-select" style="flex: 1;">
<option value="">--请选择模型--</option>
</select>
</div>
<div id="wechat-multi-import-model-input-wrapper" style="flex: 1; display: none;">
<input type="text" class="wechat-settings-input"
id="wechat-multi-import-model-input"
placeholder="手动输入模型名">
</div>
<button class="wechat-btn wechat-btn-small" id="wechat-multi-import-model-toggle">手动</button>
<button class="wechat-btn wechat-btn-small wechat-btn-primary" id="wechat-multi-import-fetch-model">获取</button>
</div>
</div>
<button class="wechat-btn" id="wechat-multi-import-test" style="width: 100%; margin-top: 8px;">
测试连接
</button>
</div>
<!-- 使用全局配置提示 -->
<div id="wechat-multi-import-global-tip" style="margin-top: 8px; font-size: 12px; color: var(--wechat-text-secondary);">
将使用全局 AI 配置进行解析
</div>
</div>
<!-- 文件选择区 -->
<div class="wechat-settings-section" style="margin-top: 16px;">
<div class="wechat-settings-title">选择文件</div>
<div style="display: flex; gap: 10px;">
<button class="wechat-btn" id="wechat-multi-import-select-png" style="flex: 1;">
选择 PNG 文件
</button>
<button class="wechat-btn" id="wechat-multi-import-select-json" style="flex: 1;">
选择 JSON 文件
</button>
</div>
<div id="wechat-multi-import-file-info" style="margin-top: 8px; font-size: 13px; color: var(--wechat-text-secondary);">
未选择文件
</div>
</div>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-multi-import-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-multi-import-start" disabled>开始解析</button>
</div>
</div>
</div>
<!-- 角色选择弹窗(选择导入哪些角色为联系人/群聊) -->
<div id="wechat-char-select-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 450px; max-height: 80vh; display: flex; flex-direction: column;">
<div class="wechat-modal-header">
<span>选择要导入的角色</span>
<span class="wechat-modal-close" id="wechat-char-select-close">&times;</span>
</div>
<div class="wechat-modal-body" style="flex: 1; overflow-y: auto; padding: 0;">
<!-- 角色列表区 -->
<div style="padding: 12px; border-bottom: 1px solid var(--wechat-border);">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<input type="checkbox" id="wechat-char-select-all" checked style="margin-right: 8px;">
<label for="wechat-char-select-all" style="font-weight: bold;">创建独立联系人</label>
<span id="wechat-char-select-count" style="margin-left: auto; font-size: 12px; color: var(--wechat-text-secondary);">0/0</span>
</div>
<div id="wechat-char-select-list" style="max-height: 250px; overflow-y: auto;">
<!-- 角色列表动态填充 -->
</div>
</div>
<!-- 群聊选项区 -->
<div style="padding: 12px;">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<input type="checkbox" id="wechat-char-select-group" checked style="margin-right: 8px;">
<label for="wechat-char-select-group" style="font-weight: bold;">同时创建群聊</label>
</div>
<div id="wechat-char-select-group-options">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div id="wechat-char-select-group-avatar" style="width: 48px; height: 48px; background: #fff; border: 1px solid #ddd; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; color: #000;">群</div>
<input type="text" id="wechat-char-select-group-name" class="wechat-settings-input" placeholder="群聊名称(可选)" style="flex: 1;">
</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">
将包含上方勾选的联系人至少需要2人
</div>
</div>
</div>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-char-select-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-char-select-confirm">确认导入</button>
</div>
</div>
</div>
<!-- "其它信息"编辑弹窗 -->
<div id="wechat-char-other-edit-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 400px;">
<div class="wechat-modal-header">
<span id="wechat-char-other-edit-title">编辑其它信息</span>
<span class="wechat-modal-close" id="wechat-char-other-edit-close">&times;</span>
</div>
<div class="wechat-modal-body" style="padding: 16px;">
<textarea id="wechat-char-other-edit-textarea"
class="wechat-settings-input"
style="width: 100%; height: 200px; resize: vertical; font-size: 14px; line-height: 1.5;"
placeholder="其它信息"></textarea>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-char-other-edit-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-char-other-edit-save">保存</button>
</div>
</div>
</div>
<!-- 玩具控制页面 --> <!-- 玩具控制页面 -->
<div id="wechat-toy-control-page" class="wechat-toy-control-page hidden"> <div id="wechat-toy-control-page" class="wechat-toy-control-page hidden">
<div class="wechat-navbar wechat-toy-control-navbar"> <div class="wechat-navbar wechat-toy-control-navbar">

308
proactive-message.js Normal file
View File

@@ -0,0 +1,308 @@
/**
* 角色主动发消息系统
* 规则每2-3轮随机触发保底4轮必触发
*/
import { requestSave } from './save-manager.js';
import { getSettings, splitAIMessages } from './config.js';
import { refreshChatList } from './ui.js';
import { showNotificationBanner } from './toast.js';
import { buildSystemPrompt } from './ai.js';
// 配置
const CONFIG = {
minRounds: 2, // 最少2轮后可触发
maxRounds: 3, // 随机2-3轮
guaranteeRounds: 4, // 保底4轮必触发
cooldownMs: 30 * 1000, // 30秒冷却防止刷屏
groupEmotionChance: 0.3 // 群聊情绪触发概率30%
};
/**
* 生成随机阈值 (2-3)
*/
function randomThreshold() {
return CONFIG.minRounds + Math.floor(Math.random() * (CONFIG.maxRounds - CONFIG.minRounds + 1));
}
/**
* 格式化时间字符串
*/
function formatTimeStr(date) {
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
/**
* 发送主动消息
* @param {object} contact - 联系人对象
* @param {string} type - 消息类型:'daily' | 'angry_private' | 'want_private'
* @param {Array} groupContext - 群聊上下文(可选,用于群聊触发的私聊)
*/
async function sendProactiveMessage(contact, type = 'daily', groupContext = []) {
const prompts = {
// 日常主动消息
daily: `[你现在要主动给用户发一条消息。可以是:
1. 分享你正在做的事情
2. 想起用户了,打个招呼
3. 看到什么有趣的东西想分享
4. 撒娇或关心用户
根据你的性格和当前心情发1-2条简短消息像真实聊天一样自然。]`,
// 群聊生气后私下发
angry_private: `[你刚才在群聊里和用户有些不愉快,现在想私下和用户说点什么。
可以是:生气、委屈、想解释、想和好,或者继续吵。
根据你的性格决定态度发1-2条消息。]`,
// 群聊中想私聊
want_private: `[你在群聊里聊到一些话题,想私下单独和用户聊点事情。
发一条消息说明原因,像"有件事想单独跟你说"这样自然的开场。]`,
};
try {
// 如果是群聊触发的私聊,需要特殊处理
if ((type === 'angry_private' || type === 'want_private') && groupContext.length > 0) {
// 使用带群聊上下文的 AI 调用
const response = await callAIWithGroupContext(contact, prompts[type], groupContext);
await processProactiveResponse(contact, response, type);
} else {
// 普通主动消息,使用标准 callAI
const { callAI } = await import('./ai.js');
const response = await callAI(contact, prompts[type] || prompts.daily);
await processProactiveResponse(contact, response, type);
}
} catch (err) {
console.error('[可乐] 主动消息发送失败:', err);
}
}
/**
* 处理主动消息的响应
*/
async function processProactiveResponse(contact, response, type) {
const messages = splitAIMessages(response);
const now = new Date();
const timeStr = formatTimeStr(now);
if (!contact.chatHistory) contact.chatHistory = [];
for (const msg of messages) {
const content = msg.trim();
if (!content) continue;
contact.chatHistory.push({
role: 'assistant',
content: content,
time: timeStr,
timestamp: Date.now(),
isProactive: true // 标记为主动消息
});
contact.unreadCount = (contact.unreadCount || 0) + 1;
contact.lastMessage = content;
}
requestSave();
refreshChatList();
// 显示通知横幅
const previewText = messages[0]?.substring(0, 15) || '';
showNotificationBanner('微信', `${contact.name}: ${previewText}${previewText.length >= 15 ? '...' : ''}`);
console.log(`[可乐] ${contact.name} 主动发消息 (${type})`);
}
/**
* 带群聊上下文的 AI 调用
* 用于群聊触发的私聊确保AI知道群里发生了什么
* @param {object} contact - 联系人对象
* @param {string} userMessage - 用户消息(提示词)
* @param {Array} groupContext - 群聊上下文
*/
async function callAIWithGroupContext(contact, userMessage, groupContext) {
const { getApiConfig, fetchWithRetry, formatApiError } = await import('./ai.js');
const settings = getSettings();
// 获取 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('请先选择模型');
// 构建系统提示词(包含用户设定和世界书)
const systemPrompt = buildSystemPrompt(contact);
// 构建消息数组
const messages = [{ role: 'system', content: systemPrompt }];
// 添加群聊上下文(作为背景信息)
if (groupContext.length > 0) {
// 将群聊上下文格式化为一条系统消息
const groupContextText = groupContext.map(msg => {
const sender = msg.characterName || (msg.role === 'user' ? '用户' : '未知');
return `${sender}: ${msg.content}`;
}).join('\n');
messages.push({
role: 'user',
content: `[以下是刚才群聊中的对话记录,你需要根据这些内容来决定私聊时说什么]\n\n${groupContextText}\n\n[群聊记录结束]`
});
messages.push({
role: 'assistant',
content: '好的,我已经了解了群聊中发生的事情。'
});
}
// 添加私聊历史记录最近10条让AI知道私聊的上下文
const chatHistory = contact.chatHistory || [];
const recentPrivateHistory = chatHistory.slice(-10);
recentPrivateHistory.forEach(msg => {
if (msg.isMarker) return;
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
});
});
// 添加当前提示词
messages.push({ role: 'user', content: userMessage });
// 调用 API
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: 1,
max_tokens: 8196
})
},
{ maxRetries: 3 }
);
if (!response.ok) {
throw new Error(await formatApiError(response, { retries: 0 }));
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '...';
}
/**
* 用户发消息后调用,检查其他联系人是否要主动发消息
* @param {string} currentContactId - 当前聊天的联系人ID
*/
export async function checkOtherContactsProactive(currentContactId) {
const settings = getSettings();
for (const contact of settings.contacts) {
// 跳过当前聊天的联系人
if (contact.id === currentContactId) continue;
// 跳过被拉黑的
if (contact.isBlocked) continue;
// 跳过没有聊过天的(避免陌生人突然发消息)
if (!contact.chatHistory || contact.chatHistory.length === 0) continue;
// 初始化计数器
if (typeof contact.proactiveCounter !== 'number') {
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
}
// 递增计数
contact.proactiveCounter++;
// 检查是否触发
const shouldTrigger =
contact.proactiveCounter >= CONFIG.guaranteeRounds || // 保底4轮
contact.proactiveCounter >= contact.proactiveThreshold; // 随机阈值
if (!shouldTrigger) continue;
// 检查冷却时间
if (Date.now() - (contact.lastProactiveAt || 0) < CONFIG.cooldownMs) {
continue;
}
// 重置计数器和阈值
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
contact.lastProactiveAt = Date.now();
// 触发主动消息
await sendProactiveMessage(contact, 'daily');
}
requestSave();
}
/**
* 群聊中检测到情绪后调用
* @param {string} contactId - 联系人ID
* @param {string} emotionType - 情绪类型:'negative' | 'want_private'
* @param {Array} groupContext - 群聊上下文最近40条消息
*/
export async function triggerProactiveFromGroup(contactId, emotionType, groupContext = []) {
const settings = getSettings();
const contact = settings.contacts.find(c => c.id === contactId);
if (!contact || contact.isBlocked) return;
// 检查冷却
if (Date.now() - (contact.lastProactiveAt || 0) < CONFIG.cooldownMs) {
return;
}
// 群聊情绪触发有独立的概率
if (Math.random() > CONFIG.groupEmotionChance) {
console.log(`[可乐] ${contact.name} 群聊情绪触发未命中概率 (${CONFIG.groupEmotionChance * 100}%)`);
return;
}
contact.lastProactiveAt = Date.now();
requestSave();
// 立即发送,传递群聊上下文
const messageType = emotionType === 'negative' ? 'angry_private' : 'want_private';
console.log(`[可乐] ${contact.name} 群聊情绪触发私聊 (${messageType}),群聊上下文 ${groupContext.length}`);
await sendProactiveMessage(contact, messageType, groupContext);
}
/**
* 重置某个联系人的主动消息计数器
* @param {string} contactId - 联系人ID
*/
export function resetProactiveCounter(contactId) {
const settings = getSettings();
const contact = settings.contacts.find(c => c.id === contactId);
if (contact) {
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
requestSave();
}
}
export { sendProactiveMessage };

View File

@@ -379,7 +379,13 @@ function cleanAIReply(text) {
// 移除特殊标记 // 移除特殊标记
reply = reply.replace(/\[.*?\]/g, '').trim(); reply = reply.replace(/\[.*?\]/g, '').trim();
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
// 移除 meme 表情包标签(多种可能的格式)
reply = reply.replace(/<\s*meme\s*>[^<]*<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<meme>[^<]*<\/meme>/gi, '').trim();
// 移除可能残留的单独标签
reply = reply.replace(/<\/?meme>/gi, '').trim();
// 移除括号描述(中文和英文括号) // 移除括号描述(中文和英文括号)
reply = reply.replace(/[^]+/g, '').trim(); reply = reply.replace(/[^]+/g, '').trim();
@@ -828,20 +834,9 @@ export async function hangupCall() {
} }
} }
// 通话记录消息 // 保存通话历史和对话内容
const callRecord = {
role: callState.initiator === 'user' ? 'user' : 'assistant',
content: callContent,
time: timeStr,
timestamp: Date.now(),
isCallRecord: true,
isRealVoice: true
};
contact.chatHistory.push(callRecord);
// 保存通话历史
if (callState.messages && callState.messages.length > 0) { if (callState.messages && callState.messages.length > 0) {
// 有对话内容时,直接将对话内容保存到 chatHistory不显示通话记录标记
contact.realVoiceCallHistory = Array.isArray(contact.realVoiceCallHistory) ? contact.realVoiceCallHistory : []; contact.realVoiceCallHistory = Array.isArray(contact.realVoiceCallHistory) ? contact.realVoiceCallHistory : [];
contact.realVoiceCallHistory.push({ contact.realVoiceCallHistory.push({
type: 'real-voice', type: 'real-voice',
@@ -851,15 +846,42 @@ export async function hangupCall() {
timestamp: Date.now(), timestamp: Date.now(),
messages: callState.messages.map(m => ({ role: m.role, content: m.content })) messages: callState.messages.map(m => ({ role: m.role, content: m.content }))
}); });
// 将通话对话内容保存到 chatHistory让 AI 后续对话能识别
callState.messages.forEach(msg => {
contact.chatHistory.push({
role: msg.role,
content: msg.content,
time: timeStr,
timestamp: Date.now(),
isRealVoiceContent: true // 标记为实时通话内容
});
});
// 刷新聊天界面显示对话内容
if (currentChatIndex === callState.contactIndex) {
openChat(currentChatIndex);
}
} else {
// 没有对话内容时(取消/拒绝),显示通话记录标记
const callRecord = {
role: callState.initiator === 'user' ? 'user' : 'assistant',
content: callContent,
time: timeStr,
timestamp: Date.now(),
isCallRecord: true,
isRealVoice: true
};
contact.chatHistory.push(callRecord);
// 在聊天界面显示通话记录
if (currentChatIndex === callState.contactIndex) {
appendCallRecordMessage(callState.initiator === 'user' ? 'user' : 'assistant', durationStr, contact);
}
} }
contact.lastMessage = lastMessage; contact.lastMessage = lastMessage;
// 在聊天界面显示通话记录
if (currentChatIndex === callState.contactIndex) {
appendCallRecordMessage(callState.initiator === 'user' ? 'user' : 'assistant', durationStr, contact);
}
requestSave(); requestSave();
refreshChatList(); refreshChatList();
} }

526
style.css
View File

@@ -276,6 +276,43 @@
background: #d9363e; background: #d9363e;
} }
/* 多人群聊卡片 */
.wechat-mp-card {
position: relative;
}
.wechat-mp-card .wechat-card-avatar {
background: linear-gradient(135deg, #667eea, #764ba2);
position: relative;
}
.wechat-mp-avatar {
position: relative;
}
.wechat-mp-api-badge {
position: absolute;
bottom: -2px;
right: -2px;
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.wechat-mp-card-content {
position: relative;
}
.wechat-mp-card .wechat-card-name {
color: var(--wechat-text);
}
/* ===== 搜索框 ===== */ /* ===== 搜索框 ===== */
.wechat-search-box { .wechat-search-box {
padding: 8px 12px; padding: 8px 12px;
@@ -770,6 +807,44 @@
border-left-color: var(--wechat-bubble-self); border-left-color: var(--wechat-bubble-self);
} }
/* ===== 多人群聊样式 ===== */
.wechat-mp-message {
flex-direction: row;
margin-left: 0;
}
.wechat-mp-message .wechat-message-content {
margin-left: 12px;
margin-right: 0;
max-width: 85%;
}
.wechat-mp-message .wechat-message-bubble {
margin-left: 0;
}
.wechat-mp-message .wechat-message-bubble:first-child::before {
display: none;
}
.wechat-mp-sender {
font-size: 12px;
color: var(--wechat-text-secondary);
margin-bottom: 2px;
padding-left: 2px;
}
/* 暗色模式下多人群聊名字颜色 */
.wechat-dark .wechat-mp-sender {
color: rgba(255, 255, 255, 0.7);
}
/* 浅色模式下多人群聊名字颜色 */
.wechat-light .wechat-mp-sender,
.wechat-mp-sender {
color: rgba(0, 0, 0, 0.6);
}
/* 语音消息 */ /* 语音消息 */
.wechat-voice { .wechat-voice {
display: flex; display: flex;
@@ -2304,9 +2379,15 @@
color: inherit; color: inherit;
} }
/* 用户消息的图标朝向左(水平翻转) */ /* 用户消息的图标朝向左(水平翻转)+ 黑色 */
.wechat-voice-bubble.self .wechat-voice-waves-icon { .wechat-voice-bubble.self .wechat-voice-waves-icon {
transform: scaleX(-1); transform: scaleX(-1);
color: #000;
}
/* 用户消息的时长文字也是黑色 */
.wechat-voice-bubble.self .wechat-voice-duration {
color: #000;
} }
/* 对方消息的图标朝向右(默认方向) */ /* 对方消息的图标朝向右(默认方向) */
@@ -5231,7 +5312,7 @@
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 120px; bottom: 165px;
max-height: 250px; max-height: 250px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -5301,10 +5382,14 @@
} }
.wechat-video-call-input-area { .wechat-video-call-input-area {
position: absolute;
bottom: 110px;
left: 16px;
right: 16px;
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 8px 0; padding: 8px 0;
margin: 0 16px 10px 16px; z-index: 20;
} }
.wechat-video-call-input-area.hidden { .wechat-video-call-input-area.hidden {
@@ -13039,3 +13124,438 @@
font-size: 12px; font-size: 12px;
color: #07c160; color: #07c160;
} }
/* ========== 多人卡导入 - 角色表格样式 ========== */
/* 表格空状态 */
.wechat-char-tables-empty {
text-align: center;
padding: 30px 20px;
color: var(--wechat-text-secondary);
}
/* 表格卡片 */
.wechat-char-table-card {
background: var(--wechat-bg-secondary);
border-radius: 8px;
margin-bottom: 10px;
overflow: hidden;
}
.wechat-char-table-card.modified {
background: linear-gradient(135deg, rgba(87, 107, 149, 0.08), rgba(87, 107, 149, 0.03));
}
/* 表格标题栏 */
.wechat-char-table-header {
display: flex;
align-items: center;
padding: 12px 15px;
cursor: pointer;
user-select: none;
}
.wechat-char-table-header:hover {
background: var(--wechat-bg-tertiary);
}
.wechat-char-table-arrow {
font-size: 10px;
color: var(--wechat-text-secondary);
margin-right: 8px;
transition: transform 0.2s;
}
.wechat-char-table-card.expanded .wechat-char-table-arrow {
transform: rotate(90deg);
}
.wechat-char-table-title {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wechat-char-table-badge {
font-size: 11px;
padding: 2px 8px;
background: rgba(87, 107, 149, 0.1);
color: #576B95;
border-radius: 10px;
margin-left: 8px;
}
.wechat-char-table-modified-tip {
font-size: 11px;
color: #f5a623;
margin-left: 8px;
}
.wechat-char-table-delete {
background: none;
border: none;
color: var(--wechat-text-secondary);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
margin-left: 8px;
opacity: 0.6;
transition: opacity 0.2s, color 0.2s;
}
.wechat-char-table-header:hover .wechat-char-table-delete {
opacity: 1;
}
.wechat-char-table-delete:hover {
color: #ff4d4f;
}
/* 表格内容区 */
.wechat-char-table-body {
padding: 0 15px 15px;
}
/* 表格样式 */
.wechat-char-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.wechat-char-table th {
text-align: left;
padding: 8px 6px;
color: var(--wechat-text-secondary);
font-weight: normal;
font-size: 12px;
border-bottom: 1px solid var(--wechat-border);
}
.wechat-char-table td {
padding: 6px;
border-bottom: 1px solid var(--wechat-border);
}
.wechat-char-table tr:last-child td {
border-bottom: none;
}
/* 编辑输入框 */
.wechat-char-edit-input {
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--wechat-text);
font-size: 13px;
box-sizing: border-box;
transition: border-color 0.2s, background 0.2s;
}
.wechat-char-edit-input:hover {
background: var(--wechat-bg-tertiary);
}
.wechat-char-edit-input:focus {
outline: none;
border-color: #576B95;
background: var(--wechat-bg);
}
.wechat-char-edit-input.char-name {
font-weight: 500;
}
.wechat-char-edit-input.char-age {
text-align: center;
}
/* 删除行按钮 */
.wechat-char-row-delete {
background: none;
border: none;
color: var(--wechat-text-secondary);
cursor: pointer;
font-size: 16px;
padding: 4px;
opacity: 0;
transition: opacity 0.2s, color 0.2s;
}
.wechat-char-table tr:hover .wechat-char-row-delete {
opacity: 1;
}
.wechat-char-row-delete:hover {
color: #ff4d4f;
}
/* 添加行按钮 */
.wechat-char-add-row {
display: block;
width: 100%;
padding: 10px;
margin-top: 10px;
border: 1px dashed var(--wechat-border);
border-radius: 6px;
background: transparent;
color: var(--wechat-text-secondary);
font-size: 13px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.wechat-char-add-row:hover {
border-color: #576B95;
color: #576B95;
background: rgba(87, 107, 149, 0.05);
}
/* 表格底部操作栏 */
.wechat-char-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--wechat-border);
}
.wechat-char-table-time {
font-size: 11px;
color: var(--wechat-text-secondary);
}
.wechat-char-table-actions {
display: flex;
gap: 8px;
}
.wechat-char-table-save {
background: rgba(87, 107, 149, 0.1) !important;
color: #576B95 !important;
}
.wechat-char-table-save:hover {
background: rgba(87, 107, 149, 0.2) !important;
}
/* 表格内容区滚动容器 */
.wechat-char-table-scroll {
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(87, 107, 149, 0.3) transparent;
}
.wechat-char-table-scroll::-webkit-scrollbar {
width: 6px;
}
.wechat-char-table-scroll::-webkit-scrollbar-track {
background: transparent;
}
.wechat-char-table-scroll::-webkit-scrollbar-thumb {
background: rgba(87, 107, 149, 0.3);
border-radius: 3px;
}
.wechat-char-table-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(87, 107, 149, 0.5);
}
/* 其它信息文本域 */
.wechat-char-textarea {
width: 100%;
min-height: 60px;
max-height: 150px;
resize: vertical;
font-family: inherit;
font-size: 13px;
line-height: 1.4;
padding: 6px 8px;
border: 1px solid var(--wechat-border);
border-radius: 4px;
background: var(--wechat-bg-primary);
color: var(--wechat-text-primary);
box-sizing: border-box;
}
.wechat-char-textarea:focus {
outline: none;
border-color: #576B95;
box-shadow: 0 0 0 2px rgba(87, 107, 149, 0.1);
}
.wechat-char-textarea::placeholder {
color: var(--wechat-text-secondary);
opacity: 0.6;
}
/* 世界观区域 */
.wechat-worldview-section {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--wechat-border);
}
.wechat-worldview-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.wechat-worldview-title {
font-size: 13px;
font-weight: 500;
color: var(--wechat-text-primary);
}
.wechat-worldview-textarea {
width: 100%;
min-height: 100px;
max-height: 200px;
resize: vertical;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
padding: 10px 12px;
border: 1px solid var(--wechat-border);
border-radius: 6px;
background: var(--wechat-bg-primary);
color: var(--wechat-text-primary);
box-sizing: border-box;
}
.wechat-worldview-textarea:focus {
outline: none;
border-color: #576B95;
box-shadow: 0 0 0 2px rgba(87, 107, 149, 0.1);
}
.wechat-worldview-textarea::placeholder {
color: var(--wechat-text-secondary);
opacity: 0.6;
}
/* 角色列表区域 */
.wechat-characters-section {
margin-bottom: 12px;
}
.wechat-characters-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.wechat-characters-title {
font-size: 13px;
font-weight: 500;
color: var(--wechat-text-primary);
}
/* 解析预览弹窗 */
.wechat-preview-section {
background: var(--wechat-bg-secondary);
border-radius: 8px;
padding: 12px;
}
.wechat-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.wechat-preview-title {
font-size: 14px;
font-weight: 500;
color: var(--wechat-text-primary);
}
.wechat-preview-badge {
font-size: 11px;
padding: 2px 8px;
background: rgba(87, 107, 149, 0.1);
color: #576B95;
border-radius: 10px;
}
.wechat-preview-content {
font-size: 13px;
line-height: 1.5;
color: var(--wechat-text-primary);
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
padding: 10px;
background: var(--wechat-bg-primary);
border-radius: 6px;
border: 1px solid var(--wechat-border);
}
.wechat-preview-list {
max-height: 300px;
overflow-y: auto;
}
.wechat-preview-char-item {
display: flex;
align-items: flex-start;
padding: 10px;
margin-bottom: 8px;
background: var(--wechat-bg-primary);
border-radius: 6px;
border: 1px solid var(--wechat-border);
}
.wechat-preview-char-avatar {
width: 40px;
height: 40px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
color: #000;
flex-shrink: 0;
margin-right: 12px;
}
.wechat-preview-char-info {
flex: 1;
min-width: 0;
}
.wechat-preview-char-name {
font-size: 14px;
font-weight: 500;
color: var(--wechat-text-primary);
margin-bottom: 4px;
}
.wechat-preview-char-meta {
font-size: 12px;
color: var(--wechat-text-secondary);
margin-bottom: 4px;
}
.wechat-preview-char-other {
font-size: 12px;
color: var(--wechat-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

105
ui.js
View File

@@ -100,11 +100,12 @@ export function getUserPersonaFromST() {
return null; return null;
} }
// 生成聊天列表 HTML包含单聊群聊) // 生成聊天列表 HTML包含单聊、群聊和多人群聊)
export function generateChatList() { export function generateChatList() {
const settings = getSettings(); const settings = getSettings();
const contacts = settings.contacts || []; const contacts = settings.contacts || [];
const groupChats = settings.groupChats || []; const groupChats = settings.groupChats || [];
const multiPersonChats = settings.multiPersonChats || [];
// 处理单聊 // 处理单聊
const contactsWithChat = contacts.map((contact, index) => { const contactsWithChat = contacts.map((contact, index) => {
@@ -136,8 +137,22 @@ export function generateChatList() {
}; };
}); });
// 处理多人群聊
const multiPersonWithChat = multiPersonChats.map((chat, index) => {
const chatHistory = chat.chatHistory || [];
const lastMsg = getLastRenderableMessage(chatHistory);
const lastMsgTime = lastMsg ? (lastMsg.timestamp || chat.lastMessageTime || 0) : (chat.lastMessageTime || 0);
return {
type: 'multiPerson',
...chat,
originalIndex: index,
lastMsg,
lastMsgTime: lastMsgTime || Date.now()
};
});
// 合并并排序 // 合并并排序
const allChats = [...contactsWithChat, ...groupsWithChat].sort((a, b) => b.lastMsgTime - a.lastMsgTime); const allChats = [...contactsWithChat, ...groupsWithChat, ...multiPersonWithChat].sort((a, b) => b.lastMsgTime - a.lastMsgTime);
if (allChats.length === 0) { if (allChats.length === 0) {
return ` return `
@@ -155,6 +170,8 @@ export function generateChatList() {
return allChats.map(chat => { return allChats.map(chat => {
if (chat.type === 'group') { if (chat.type === 'group') {
return generateGroupChatItem(chat, settings); return generateGroupChatItem(chat, settings);
} else if (chat.type === 'multiPerson') {
return generateMultiPersonChatItem(chat);
} else { } else {
return generateContactChatItem(chat); return generateContactChatItem(chat);
} }
@@ -163,7 +180,9 @@ export function generateChatList() {
// 生成单聊列表项 // 生成单聊列表项
function generateContactChatItem(contact) { function generateContactChatItem(contact) {
if (!contact) return '';
const lastMsg = contact.lastMsg; const lastMsg = contact.lastMsg;
if (!lastMsg) return '';
let preview = ''; let preview = '';
if (lastMsg.type === 'voice' || lastMsg.isVoice) { if (lastMsg.type === 'voice' || lastMsg.isVoice) {
preview = '[语音]'; preview = '[语音]';
@@ -319,13 +338,61 @@ function generateGroupChatItem(group, settings) {
`; `;
} }
// 生成多人群聊列表项
function generateMultiPersonChatItem(chat) {
const lastMsg = chat.lastMsg;
let preview = '';
if (lastMsg) {
const sender = lastMsg.characterName ? `[${lastMsg.characterName}]: ` : '';
if (lastMsg.isVoice) {
preview = `${sender}[语音]`;
} else if (lastMsg.isImage) {
preview = `${sender}[图片]`;
} else if (lastMsg.isSticker) {
preview = `${sender}[表情]`;
} else {
let content = lastMsg.content || '';
if (content.length > 15) content = content.substring(0, 15) + '...';
preview = `${sender}${content}`;
}
} else {
preview = '群聊已创建';
}
const msgTime = chat.lastMsgTime ? formatChatTime(chat.lastMsgTime) : '';
const memberCount = chat.members?.length || 0;
// 使用保存的头像,如果没有则显示白底黑字"群"
let avatarHtml;
if (chat.avatar) {
avatarHtml = `<img src="${chat.avatar}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px;">`;
} else {
avatarHtml = `<div style="width: 100%; height: 100%; background: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: bold; color: #000;">群</div>`;
}
return `
<div class="wechat-chat-item wechat-chat-item-mp" data-mp-id="${chat.id}" data-mp-index="${chat.originalIndex}">
<div class="wechat-chat-item-avatar" style="display: flex; align-items: center; justify-content: center;">${avatarHtml}</div>
<div class="wechat-chat-item-info">
<div class="wechat-chat-item-name">${escapeHtml(chat.name || '群聊')}(${memberCount})</div>
<div class="wechat-chat-item-preview">${escapeHtml(preview)}</div>
</div>
<div class="wechat-chat-item-meta">
<span class="wechat-chat-item-time">${msgTime}</span>
</div>
</div>
`;
}
// 生成联系人列表 HTML // 生成联系人列表 HTML
export function generateContactsList() { export function generateContactsList() {
const settings = getSettings(); const settings = getSettings();
const contacts = settings.contacts || []; const contacts = settings.contacts || [];
const groupChats = settings.groupChats || []; const groupChats = settings.groupChats || [];
const multiPersonChats = settings.multiPersonChats || [];
if (contacts.length === 0 && groupChats.length === 0) { if (contacts.length === 0 && groupChats.length === 0 && multiPersonChats.length === 0) {
return ` return `
<div class="wechat-empty"> <div class="wechat-empty">
<div class="wechat-empty-icon"> <div class="wechat-empty-icon">
@@ -403,6 +470,38 @@ export function generateContactsList() {
`; `;
}); });
// 生成多人群聊卡片
multiPersonChats.forEach((mpChat, index) => {
const memberCount = mpChat.members?.length || 0;
const chatName = mpChat.name || '群聊';
const hasCustomApi = mpChat.useCustomApi || false;
// 使用保存的头像,如果没有则显示白底黑字"群"
let avatarHtml;
if (mpChat.avatar) {
avatarHtml = `<img src="${mpChat.avatar}" alt="" style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px;">`;
} else {
avatarHtml = `<div class="wechat-card-fallback" style="display:flex; background: #fff; color: #000; font-weight: bold;">群</div>`;
}
html += `
<div class="wechat-contact-card wechat-mp-card" data-mp-index="${index}">
<div class="wechat-card-swipe-wrapper">
<div class="wechat-card-content wechat-mp-card-content" data-mp-index="${index}" title="点击开始聊天">
<div class="wechat-card-avatar wechat-mp-avatar" data-mp-index="${index}" title="点击配置API">
${avatarHtml}
${hasCustomApi ? '<div class="wechat-mp-api-badge">⚙️</div>' : ''}
</div>
<div class="wechat-card-name">${escapeHtml(chatName)}(${memberCount})</div>
</div>
<div class="wechat-card-delete wechat-mp-delete" data-mp-index="${index}">
<span>删除</span>
</div>
</div>
</div>
`;
});
// 生成联系人卡片 // 生成联系人卡片
contacts.forEach((contact, index) => { contacts.forEach((contact, index) => {
const firstChar = contact.name ? contact.name.charAt(0) : '?'; const firstChar = contact.name ? contact.name.charAt(0) : '?';

View File

@@ -729,7 +729,9 @@ async function triggerAIGreeting() {
let reply = part.trim(); let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤) // 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim(); reply = reply.replace(/<\s*meme\s*>[^<]*<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<meme>[^<]*<\/meme>/gi, '').trim();
reply = reply.replace(/<\/?meme>/gi, '').trim();
if (!reply) continue; if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue; if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除语音标记 // 移除语音标记
@@ -854,7 +856,9 @@ ${lastMessages}
for (const part of parts) { for (const part of parts) {
let reply = part.trim(); let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤) // 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim(); reply = reply.replace(/<\s*meme\s*>[^<]*<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<meme>[^<]*<\/meme>/gi, '').trim();
reply = reply.replace(/<\/?meme>/gi, '').trim();
if (!reply) continue; if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue; if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除可能的特殊标记 // 移除可能的特殊标记