diff --git a/ai.js b/ai.js index 4bfe239..0b04feb 100644 --- a/ai.js +++ b/ai.js @@ -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() { const config = getApiConfig(); diff --git a/chat.js b/chat.js index 93babe7..d777cd7 100644 --- a/chat.js +++ b/chat.js @@ -753,6 +753,11 @@ export function openChat(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打开聊天 @@ -2488,6 +2493,13 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi // 尝试触发语音/视频通话(随机触发+保底机制) tryTriggerCallAfterChat(contactIndex); + // 检查其他联系人是否要主动发消息 + import('./proactive-message.js').then(m => { + m.checkOtherContactsProactive(contact.id); + }).catch(err => { + console.error('[可乐] 主动消息检查失败:', err); + }); + } catch (err) { hideTypingIndicator(); console.error('[可乐] AI 调用失败:', err); diff --git a/contacts.js b/contacts.js index 0aabc5c..4a27b09 100644 --- a/contacts.js +++ b/contacts.js @@ -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 = ``; + } 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 = ''; + if (mpChat.customModel) { + modelSelect.innerHTML += ``; + } + } + + // 显示弹窗 + 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 = ``; + } + }; + reader.readAsDataURL(file); +} + +// 关闭多人群聊API配置弹窗 +export function closeMpApiSettings() { + document.getElementById('wechat-mp-api-modal')?.classList.add('hidden'); + currentEditingMpIndex = -1; +} + // 更换角色头像(在设置弹窗中使用) export function changeContactAvatar(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 => { let pressTimer = null; diff --git a/group-chat.js b/group-chat.js index dfe553f..efbf752 100644 --- a/group-chat.js +++ b/group-chat.js @@ -635,6 +635,11 @@ export function openGroupChat(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(); checkGroupSummaryReminder(groupChat); + // 检测群聊中的负面情绪,可能触发私聊 + // 传递群聊上下文(最近40条消息) + const groupContext = getGroupChatHistoryForApi(groupChat.chatHistory, 40); + detectGroupEmotionAndTriggerPrivate(responses, members, groupContext); + } catch (err) { hideGroupTypingIndicator(); console.error('[可乐] 群聊 AI 调用失败:', err); @@ -2932,3 +2942,194 @@ export async function sendGroupBatchMessages(messages) { 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 = ` +
+
邀请成员
+
+
+ 当前 ${currentMemberIds.length}/${GROUP_CHAT_MAX_AI_MEMBERS} 人 +
+
+ ${availableContacts.map(c => ` +
+
+ ${c.avatar ? `` : escapeHtml(c.name.charAt(0))} +
+ ${escapeHtml(c.name)} +
+ `).join('')} +
+
+
+ +
+
+ `; + + 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); + }); + } + } +} diff --git a/main.js b/main.js index d735917..36d0f4d 100644 --- a/main.js +++ b/main.js @@ -12,7 +12,7 @@ import { showPage, refreshChatList, updateMePageInfo, getUserPersonaFromST, upda import { showToast } from './toast.js'; import { ICON_SUCCESS, ICON_INFO } from './icons.js'; -import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex } from './contacts.js'; +import { 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 { refreshFavoritesList, showLorebookModal, syncCharacterBookToTavern, showAddLorebookPanel, showAddPersonaPanel } from './favorites.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 { 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 { initFuncPanel, toggleFuncPanel, hideFuncPanel, showExpandVoice, closeExpandPanel, sendExpandContent } from './chat-func-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 { testSttApi, testTtsApi } from './voice-api.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'; @@ -71,7 +73,7 @@ function closeHistoryPage() { currentHistoryContactIndex = -1; } -function deleteHistoryRecord(tabType, index) { +function deleteHistoryRecord(tabType, index, isRealVoice = false) { const settings = getSettings(); const contact = settings.contacts?.[currentHistoryContactIndex]; if (!contact) return; @@ -80,8 +82,32 @@ function deleteHistoryRecord(tabType, index) { if (contact.listenHistory && contact.listenHistory[index]) { contact.listenHistory.splice(index, 1); } - } else if (tabType === 'voice' || tabType === 'video') { - // 从 callHistory 中找到并删除对应类型的记录 + } else if (tabType === 'voice') { + 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 typeRecords = callHistory.filter(r => r.type === tabType); if (typeRecords[index]) { @@ -151,8 +177,17 @@ function renderHistoryContent(contact, tabType) { let records = []; if (tabType === 'listen') { 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 { - // 从 callHistory 中筛选 voice 或 video + // 从 callHistory 中筛选 video const callHistory = contact.callHistory || []; records = callHistory.filter(r => r.type === tabType); } @@ -179,12 +214,13 @@ function renderHistoryContent(contact, tabType) { const duration = record.duration || ''; const messages = record.messages || []; const originalIndex = records.indexOf(record); + const isRealVoice = record.isRealVoice ? 'true' : 'false'; - html += `
`; + html += `
`; html += `
`; - html += `${escapeHtml(time)}`; + html += `${escapeHtml(time)}${record.isRealVoice ? ' [实时语音]' : ''}`; html += `
`; - html += ``; + html += ``; if (duration) { html += `${escapeHtml(duration)}`; } @@ -224,7 +260,8 @@ function renderHistoryContent(contact, tabType) { e.stopPropagation(); const tab = btn.dataset.tab; 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', () => { setCurrentChatIndex(-1); setCurrentGroupChatIndex(-1); - // 清除群聊标记 + setCurrentMultiPersonChatIndex(-1); + // 清除群聊和多人群聊标记 const messagesContainer = document.getElementById('wechat-chat-messages'); if (messagesContainer) { messagesContainer.dataset.isGroup = 'false'; messagesContainer.dataset.groupIndex = '-1'; + messagesContainer.dataset.isMultiPerson = 'false'; + messagesContainer.dataset.multiPersonIndex = '-1'; // 清除背景 messagesContainer.style.backgroundImage = ''; } @@ -1026,6 +1066,12 @@ function bindEvents() { 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的朋友圈 document.getElementById('wechat-menu-moments')?.addEventListener('click', () => { document.getElementById('wechat-chat-menu')?.classList.add('hidden'); @@ -1218,6 +1264,11 @@ function bindEvents() { this.value = ''; }); + // 导入多人卡 + document.getElementById('wechat-import-multi-card')?.addEventListener('click', () => { + openMultiImportModal(); + }); + // 深色模式切换 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 = '' + + models.map(m => ``).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', () => { @@ -1584,10 +1815,15 @@ function bindEvents() { text: text.substring(0, 20), isGroup: messagesContainer?.dataset?.isGroup, 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'); sendGroupMessage(text); } else { @@ -1605,7 +1841,9 @@ function bindEvents() { const text = chatInput?.value?.trim(); if (text) { // 有文字时发送消息 - if (isInGroupChat()) { + if (isInMultiPersonChat()) { + sendMultiPersonMessage(text); + } else if (isInGroupChat()) { sendGroupMessage(text); } else { sendMessage(text); @@ -1639,6 +1877,7 @@ function bindEvents() { initGiftEvents(); initCropper(); initHistoryEvents(); + initMultiCharImport(); // 展开面板 document.getElementById('wechat-expand-close')?.addEventListener('click', closeExpandPanel); @@ -1682,7 +1921,7 @@ function bindEvents() { }); }); - // 聊天列表项点击(支持单聊和群聊) + // 聊天列表项点击(支持单聊、群聊和多人群聊) document.getElementById('wechat-chat-list')?.addEventListener('click', (e) => { const chatItem = e.target.closest('.wechat-chat-item'); if (!chatItem) return; @@ -1693,6 +1932,12 @@ function bindEvents() { if (!isNaN(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 { // 单聊 const contactId = chatItem.dataset.contactId; @@ -2029,6 +2274,13 @@ function bindEvents() { 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 || '该'; showToast(`"${label}" 功能开发中...`, 'info'); }); diff --git a/message-menu.js b/message-menu.js index deae05f..892f900 100644 --- a/message-menu.js +++ b/message-menu.js @@ -118,12 +118,24 @@ export function showMessageMenu(msgElement, msgIndex, event) { msg = contact?.chatHistory?.[msgIndex]; } - // 优先从历史记录判断,其次从元素属性判断(处理分割显示的消息) - let isUserMessage = msg?.role === 'user'; - if (msg === undefined) { - // 如果找不到消息记录,尝试从元素属性获取 - const roleAttr = msgElement?.dataset?.msgRole || msgElement?.closest?.('[data-msg-role]')?.dataset?.msgRole; - isUserMessage = roleAttr === 'user'; + // 从元素或其父元素获取 role 属性 + let roleAttr = msgElement?.dataset?.msgRole; + if (!roleAttr) { + // 尝试从父元素获取(气泡元素在 .wechat-message 内部) + const parentMsg = msgElement?.closest?.('.wechat-message') || msgElement?.parentElement?.closest?.('.wechat-message'); + 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重新回复 try { + // 等待 DOM 更新后再显示 typing 指示器 + await new Promise(resolve => setTimeout(resolve, 50)); showTypingIndicator(contact); const { callAI } = await import('./ai.js'); diff --git a/multi-char-import.js b/multi-char-import.js new file mode 100644 index 0000000..07a6a2e --- /dev/null +++ b/multi-char-import.js @@ -0,0 +1,1501 @@ +/** + * 多人卡导入模块 + * 功能:导入多人卡 PNG/JSON,AI 辅助解析,生成角色表格 + */ + +import { getSettings } from './config.js'; +import { requestSave } from './save-manager.js'; +import { showToast } from './toast.js'; +import { escapeHtml } from './utils.js'; +import { refreshContactsList } from './contacts.js'; +import { refreshChatList } from './ui.js'; + +// ========== 头像生成 ========== + +/** + * 生成文字头像(白底黑字) + * @param {string} text - 显示的文字(取第一个字符) + * @param {object} options - 可选配置 + */ +export function generateTextAvatar(text, options = {}) { + const { + size = 200, + bgColor = '#ffffff', + textColor = '#000000', + fontSize = null, + fontFamily = 'Microsoft YaHei, PingFang SC, Helvetica Neue, sans-serif' + } = options; + + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + + // 背景 + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, size, size); + + // 文字(取第一个字符) + const displayText = (text || '?').charAt(0); + const calcFontSize = fontSize || Math.floor(size * 0.5); + + ctx.fillStyle = textColor; + ctx.font = `bold ${calcFontSize}px ${fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(displayText, size / 2, size / 2); + + return canvas.toDataURL('image/png'); +} + +/** + * 生成群聊默认头像(白底 + "群"字) + */ +export function generateGroupAvatar() { + return generateTextAvatar('群'); +} + +// ========== 状态变量 ========== + +let pendingMultiImportFile = null; +let pendingParseResult = null; +let pendingOtherEdit = null; // 当前编辑的"其它"信息 { tableIdx, charIdx, btn } + +// ========== 弹窗 HTML ========== + +/** + * 获取多人卡导入弹窗 HTML + */ +export function getMultiCharImportModalHtml() { + return ` + + `; +} + +/** + * 获取角色表格选择弹窗 HTML(选择导入哪些角色为联系人/群聊) + */ +export function getCharSelectModalHtml() { + return ` + + `; +} + +/** + * 获取"其它信息"编辑弹窗 HTML + */ +export function getCharOtherEditModalHtml() { + return ` + + `; +} + +// ========== 角色表格管理 ========== + +/** + * 生成角色表格列表 HTML(在服务-AI功能区显示) + */ +export function generateCharacterTablesHtml() { + const settings = getSettings(); + const tables = settings.parsedCharacterTables || []; + + if (tables.length === 0) { + return ` +
+
📋
+
暂无角色表格
+
导入多人卡时会自动解析生成
+
+ `; + } + + return tables.map((table, idx) => { + const isExpanded = table.isExpanded || false; + const worldView = table.worldView || ''; + + return ` +
+ +
+ ${isExpanded ? '▼' : '▶'} + ${escapeHtml(table.name)} + ${table.characters.length}个角色 + + +
+ + +
+ +
+
+ 🌍 世界观 +
+ +
+ + +
+
+ 👥 角色列表 + +
+
+ + + + + + + + + + + + + ${table.characters.map((char, charIdx) => { + const otherText = typeof char.other === 'string' ? char.other : (char.other ? JSON.stringify(char.other) : ''); + const hasOther = otherText.length > 0; + return ` + + + + + + + + + `;}).join('')} + +
姓名性别年龄其它
+ + + + + + + + + + + +
+
+ + + +
+ + + +
+
+ `; + }).join(''); +} + +/** + * 添加角色表格 + */ +export function addCharacterTable(tableData) { + const settings = getSettings(); + if (!settings.parsedCharacterTables) { + settings.parsedCharacterTables = []; + } + + // 检查是否已存在同名表格 + const existingIdx = settings.parsedCharacterTables.findIndex( + t => t.name === tableData.name + ); + + if (existingIdx >= 0) { + if (confirm(`「${tableData.name}」已存在,是否覆盖?`)) { + settings.parsedCharacterTables[existingIdx] = tableData; + } else { + tableData.name = `${tableData.name} (${Date.now()})`; + settings.parsedCharacterTables.push(tableData); + } + } else { + settings.parsedCharacterTables.push(tableData); + } + + requestSave(); + refreshCharacterTablesUI(); +} + +/** + * 刷新角色表格 UI + */ +export function refreshCharacterTablesUI() { + const container = document.getElementById('wechat-char-tables-container'); + if (container) { + container.innerHTML = generateCharacterTablesHtml(); + } +} + +// ========== 弹窗操作 ========== + +/** + * 打开多人卡导入弹窗 + */ +export function openMultiImportModal() { + pendingMultiImportFile = null; + const fileInfo = document.getElementById('wechat-multi-import-file-info'); + const startBtn = document.getElementById('wechat-multi-import-start'); + + if (fileInfo) fileInfo.textContent = '未选择文件'; + if (startBtn) startBtn.disabled = true; + + document.getElementById('wechat-multi-import-modal')?.classList.remove('hidden'); +} + +/** + * 关闭多人卡导入弹窗 + */ +export function closeMultiImportModal() { + document.getElementById('wechat-multi-import-modal')?.classList.add('hidden'); + pendingMultiImportFile = null; +} + +/** + * 打开角色选择弹窗 + */ +export function openCharSelectModal(parseResult) { + pendingParseResult = parseResult; + const { characters } = parseResult; + + const listContainer = document.getElementById('wechat-char-select-list'); + if (!listContainer) return; + + // 填充角色列表 + listContainer.innerHTML = characters.map((char, idx) => { + const firstChar = (char.name || '?').charAt(0); + const genderAge = [char.gender, char.age ? `${char.age}岁` : ''].filter(Boolean).join(' · '); + return ` +
+ +
+ ${escapeHtml(firstChar)} +
+
+
${escapeHtml(char.name)}
+
+ ${genderAge ? `${genderAge} · ` : ''}${escapeHtml((char.other || '').substring(0, 30))} +
+
+
+ `; + }).join(''); + + // 更新计数 + updateCharSelectCount(); + updateCharSelectGroupName(); + + document.getElementById('wechat-char-select-modal')?.classList.remove('hidden'); +} + +/** + * 关闭角色选择弹窗 + */ +export function closeCharSelectModal() { + document.getElementById('wechat-char-select-modal')?.classList.add('hidden'); + pendingParseResult = null; +} + +/** + * 打开"其它信息"编辑弹窗 + */ +function openCharOtherEditModal(tableIdx, charIdx, otherText, btn) { + pendingOtherEdit = { tableIdx, charIdx, btn }; + + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[tableIdx]; + const charName = table?.characters?.[charIdx]?.name || '角色'; + + const titleEl = document.getElementById('wechat-char-other-edit-title'); + if (titleEl) titleEl.textContent = `${charName} - 其它信息`; + + const textarea = document.getElementById('wechat-char-other-edit-textarea'); + if (textarea) textarea.value = otherText; + + document.getElementById('wechat-char-other-edit-modal')?.classList.remove('hidden'); +} + +/** + * 关闭"其它信息"编辑弹窗 + */ +function closeCharOtherEditModal() { + document.getElementById('wechat-char-other-edit-modal')?.classList.add('hidden'); + pendingOtherEdit = null; +} + +/** + * 保存"其它信息" + */ +function saveCharOtherEdit() { + if (!pendingOtherEdit) return; + + const { tableIdx, charIdx, btn } = pendingOtherEdit; + const textarea = document.getElementById('wechat-char-other-edit-textarea'); + const newValue = textarea?.value?.trim() || ''; + + // 更新 settings + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[tableIdx]; + if (table && table.characters[charIdx]) { + table.characters[charIdx].other = newValue; + requestSave(); + } + + // 更新按钮状态 + if (btn) { + btn.dataset.other = newValue; + const hasOther = newValue.length > 0; + btn.textContent = hasOther ? '详情' : '+'; + btn.title = hasOther ? '点击查看/编辑' : '点击添加'; + btn.style.background = hasOther ? 'var(--wechat-primary)' : ''; + btn.style.color = hasOther ? 'white' : ''; + } + + closeCharOtherEditModal(); + showToast('已保存'); +} + +/** + * 绑定"其它信息"编辑弹窗事件 + */ +export function bindCharOtherEditEvents() { + document.getElementById('wechat-char-other-edit-close')?.addEventListener('click', closeCharOtherEditModal); + document.getElementById('wechat-char-other-edit-cancel')?.addEventListener('click', closeCharOtherEditModal); + document.getElementById('wechat-char-other-edit-save')?.addEventListener('click', saveCharOtherEdit); +} + +/** + * 更新选中计数 + */ +function updateCharSelectCount() { + const checkboxes = document.querySelectorAll('.wechat-char-select-check'); + const checked = Array.from(checkboxes).filter(cb => cb.checked).length; + const total = checkboxes.length; + + const countEl = document.getElementById('wechat-char-select-count'); + if (countEl) countEl.textContent = `${checked}/${total}`; + + // 如果选中少于2个,禁用群聊选项 + const groupCheckbox = document.getElementById('wechat-char-select-group'); + const groupOptions = document.getElementById('wechat-char-select-group-options'); + + if (checked < 2) { + if (groupCheckbox) groupCheckbox.checked = false; + if (groupOptions) groupOptions.style.opacity = '0.5'; + } else { + if (groupOptions) groupOptions.style.opacity = '1'; + } +} + +/** + * 更新群名 + */ +function updateCharSelectGroupName() { + if (!pendingParseResult) return; + + const checkboxes = document.querySelectorAll('.wechat-char-select-check:checked'); + const selectedNames = Array.from(checkboxes).map(cb => { + const idx = parseInt(cb.dataset.index); + return pendingParseResult.characters[idx]?.name; + }).filter(Boolean); + + const groupNameInput = document.getElementById('wechat-char-select-group-name'); + if (groupNameInput && !groupNameInput.dataset.userEdited) { + const autoName = selectedNames.slice(0, 3).join('、') + (selectedNames.length > 3 ? '...' : ''); + groupNameInput.placeholder = autoName || '群聊名称'; + } +} + +// ========== 文件处理 ========== + +/** + * 选择文件 + */ +function selectFile(accept, callback) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.onchange = (e) => { + const file = e.target.files?.[0]; + if (file) callback(file); + }; + input.click(); +} + +/** + * 处理文件选择(旧弹窗) + */ +function handleFileSelected(file) { + pendingMultiImportFile = file; + const fileInfo = document.getElementById('wechat-multi-import-file-info'); + const startBtn = document.getElementById('wechat-multi-import-start'); + + if (fileInfo) fileInfo.textContent = `已选择:${file.name}`; + if (startBtn) startBtn.disabled = false; +} + +/** + * 获取多人卡导入的 API 配置 + * 优先从弹窗输入框读取,如果弹窗未打开或未启用独立API则从 settings 读取 + */ +function getMultiImportApiConfig() { + // 检查弹窗是否打开且启用了独立API + const modal = document.getElementById('wechat-multi-import-modal'); + const customApiSwitch = document.getElementById('wechat-multi-import-custom-api'); + const isModalOpen = modal && !modal.classList.contains('hidden'); + const useCustomApi = customApiSwitch && customApiSwitch.classList.contains('on'); + + if (isModalOpen && useCustomApi) { + // 从弹窗输入框读取 + const inputWrapper = document.getElementById('wechat-multi-import-model-input-wrapper'); + const isManualMode = inputWrapper && inputWrapper.style.display !== 'none'; + + let model = ''; + if (isManualMode) { + model = document.getElementById('wechat-multi-import-model-input')?.value?.trim() || ''; + } else { + model = document.getElementById('wechat-multi-import-model-select')?.value?.trim() || ''; + } + + return { + apiUrl: document.getElementById('wechat-multi-import-api-url')?.value?.trim() || '', + apiKey: document.getElementById('wechat-multi-import-api-key')?.value?.trim() || '', + model: model + }; + } + + // 从 settings 读取已保存的配置 + const settings = getSettings(); + return { + apiUrl: settings.multiCharApiUrl || '', + apiKey: settings.multiCharApiKey || '', + model: settings.multiCharModel || '' + }; +} + +// ========== AI 解析 ========== + +/** + * 开始解析多人卡 + */ +async function startMultiImportParse() { + if (!pendingMultiImportFile) { + showToast('请先选择文件', 'warning'); + return; + } + + const config = getMultiImportApiConfig(); + if (!config.apiUrl || !config.model) { + showToast('请配置 AI 接口', 'warning'); + return; + } + + const startBtn = document.getElementById('wechat-multi-import-start'); + if (startBtn) { + startBtn.textContent = '解析中...'; + startBtn.disabled = true; + } + + try { + // 1. 解析文件 + let charData; + const fileName = pendingMultiImportFile.name; + + if (fileName.endsWith('.png')) { + const { extractCharacterFromPNG } = await import('./character-import.js'); + charData = await extractCharacterFromPNG(pendingMultiImportFile); + } else { + const { extractCharacterFromJSON } = await import('./character-import.js'); + charData = await extractCharacterFromJSON(pendingMultiImportFile); + } + + const rawData = charData.rawData || charData; + const data = rawData.data || rawData; + const entries = data.character_book?.entries || []; + + if (entries.length === 0) { + showToast('未找到世界书条目', 'warning'); + return; + } + + // 2. AI 解析每个条目 + const characters = []; + for (let i = 0; i < entries.length; i++) { + if (startBtn) startBtn.textContent = `解析中 (${i + 1}/${entries.length})...`; + + const entry = entries[i]; + const parsed = await parseEntryWithAI(entry, config); + if (parsed && parsed.name) { + characters.push({ + ...parsed, + originalEntry: entry + }); + } + } + + if (characters.length === 0) { + showToast('未解析到有效角色', 'warning'); + return; + } + + // 3. 解析世界观 + if (startBtn) startBtn.textContent = '解析世界观...'; + const worldView = await parseWorldViewWithAI(entries, config); + + // 4. 创建角色表格 + const table = { + id: 'table_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9), + name: fileName, + sourceName: charData.name || fileName, + createTime: new Date().toLocaleString('zh-CN'), + isExpanded: true, + worldView: worldView, + characters + }; + + addCharacterTable(table); + closeMultiImportModal(); + showToast(`已解析 ${characters.length} 个角色`); + + } catch (err) { + console.error('[可乐] 多人卡解析失败:', err); + showToast('解析失败: ' + err.message, 'error'); + } finally { + if (startBtn) { + startBtn.textContent = '开始解析'; + startBtn.disabled = false; + } + } +} + +/** + * 用 AI 解析单个世界书条目 + */ +async function parseEntryWithAI(entry, config) { + const content = entry.content || ''; + const entryName = entry.comment || entry.name || ''; + + const prompt = `请判断以下文本是否描述的是一个角色(人物)。 + +判断标准: +- 必须是描述人物的文本(不是地点、物品、事件、组织等) +- 必须能从文本中提取出性别(男/女)或年龄(数字)中的至少一项 + +如果是角色,返回 JSON(注意:所有字段的值都必须是字符串): +{"isCharacter": true, "name": "角色的真实姓名", "gender": "男或女", "age": "年龄数字", "other": "其它重要信息,用纯文本描述"} + +如果不是角色(比如是城市、地点、物品、组织等),返回: +{"isCharacter": false} + +重要提示: +1. name 必须是角色的真实姓名,不要用条目名称 +2. other 字段必须是纯文本字符串,不要用 JSON 对象 +3. 如果没有明确的性别或年龄信息,该条目不是角色 + +条目名称:${entryName} +条目内容: +${content} + +只返回 JSON,不要其它内容。`; + + const chatUrl = config.apiUrl.replace(/\/+$/, '') + '/chat/completions'; + const headers = { 'Content-Type': 'application/json' }; + if (config.apiKey) { + headers['Authorization'] = `Bearer ${config.apiKey}`; + } + + const response = await fetch(chatUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: 2000 + }) + }); + + if (!response.ok) { + throw new Error(`API 错误 (${response.status})`); + } + + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + + // 提取 JSON + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]); + // 如果不是角色,返回 null + if (parsed.isCharacter === false) { + return null; + } + // 检查是否有性别或年龄 + const hasGender = parsed.gender && parsed.gender !== '-' && parsed.gender !== ''; + const hasAge = parsed.age && parsed.age !== '-' && parsed.age !== ''; + if (!hasGender && !hasAge) { + return null; // 没有性别也没有年龄,不算角色 + } + // 确保 name 是字符串且不为空 + if (!parsed.name || typeof parsed.name !== 'string' || parsed.name.trim() === '') { + return null; + } + // 确保 other 是字符串 + if (parsed.other && typeof parsed.other !== 'string') { + parsed.other = JSON.stringify(parsed.other); + } + return { + name: parsed.name.trim(), + gender: String(parsed.gender || '').trim(), + age: String(parsed.age || '').trim(), + other: String(parsed.other || '').trim() + }; + } catch (e) { + console.error('[可乐] JSON 解析失败:', text); + } + } + + // 解析失败返回 null + return null; +} + +/** + * 用 AI 解析世界观信息(从所有条目中提取非角色相关的世界设定) + */ +async function parseWorldViewWithAI(entries, config) { + // 合并所有条目内容 + const allContent = entries.map(entry => { + const name = entry.comment || entry.name || ''; + const content = entry.content || ''; + return `[${name}]\n${content}`; + }).join('\n\n---\n\n'); + + const prompt = `请从以下世界书条目中提取世界观/背景设定信息。 + +要求: +1. 提取故事发生的世界观、时代背景、地点设定等 +2. 不要提取具体角色的个人信息 +3. 关注世界的规则、组织、历史、文化等设定 +4. 返回一段连贯的世界观描述文本 + +世界书内容: +${allContent} + +请直接返回世界观描述,不需要JSON格式,不需要额外解释。如果没有明确的世界观设定,返回"暂无世界观设定"。`; + + const chatUrl = config.apiUrl.replace(/\/+$/, '') + '/chat/completions'; + const headers = { 'Content-Type': 'application/json' }; + if (config.apiKey) { + headers['Authorization'] = `Bearer ${config.apiKey}`; + } + + try { + const response = await fetch(chatUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: 3000 + }) + }); + + if (!response.ok) { + console.error('[可乐] 世界观解析 API 错误:', response.status); + return ''; + } + + const data = await response.json(); + const worldView = data.choices?.[0]?.message?.content || ''; + return worldView.trim(); + } catch (err) { + console.error('[可乐] 世界观解析失败:', err); + return ''; + } +} + +// ========== 导入为联系人/群聊 ========== + +/** + * 确认导入角色为联系人/群聊 + */ +async function confirmCharSelectImport() { + if (!pendingParseResult) return; + + const settings = getSettings(); + const { characters, originalCard } = pendingParseResult; + + // 获取用户选择 + const createContacts = document.getElementById('wechat-char-select-all')?.checked !== false; + const createGroup = document.getElementById('wechat-char-select-group')?.checked; + const customGroupName = document.getElementById('wechat-char-select-group-name')?.value?.trim(); + + // 获取选中的角色 + const checkboxes = document.querySelectorAll('.wechat-char-select-check:checked'); + const selectedIndices = Array.from(checkboxes).map(cb => parseInt(cb.dataset.index)); + const selectedChars = selectedIndices.map(idx => characters[idx]).filter(Boolean); + + if (selectedChars.length === 0) { + showToast('请至少选择一个角色', 'warning'); + return; + } + + const createdContacts = []; + + // 1. 创建联系人 + if (createContacts) { + for (const char of selectedChars) { + // 检查是否已存在 + const exists = settings.contacts.some(c => c.name === char.name); + if (exists) continue; + + // 生成白底黑字头像 + const avatar = generateTextAvatar(char.name); + + const contactData = { + id: 'contact_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9), + name: char.name, + description: (char.other || '').substring(0, 50), + avatar: avatar, + importTime: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + rawData: { + data: { + name: char.name, + description: char.other || '', + personality: '', + character_book: { + entries: char.originalEntry ? [char.originalEntry] : [] + } + } + }, + useCustomApi: false, + customApiUrl: '', + customApiKey: '', + customModel: '', + customHakimiBreakLimit: false + }; + + settings.contacts.push(contactData); + createdContacts.push(contactData); + } + } + + // 2. 创建群聊(使用多人群聊模式,和角色表格发起群聊一样) + let groupCreated = false; + if (createGroup && selectedChars.length >= 2) { + 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 groupName = customGroupName || '群聊'; + + // 生成白底黑字"群"头像 + const groupAvatar = generateGroupAvatar(); + + const multiPersonChat = { + id: 'mp_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9), + name: groupName, + avatar: groupAvatar, + type: 'multi-person', + worldView: '', // 从导入弹窗创建的群聊没有世界观 + members: selectedChars.map(char => ({ + id: 'mp_member_' + Math.random().toString(36).substring(2, 9), + name: char.name, + gender: char.gender || '', + age: char.age || '', + description: char.other || '' + })), + chatHistory: [], + lastMessage: '', + lastMessageTime: Date.now(), + createdTime: timeStr, + sourceTable: originalCard?.name || '导入' + }; + + if (!settings.multiPersonChats) settings.multiPersonChats = []; + settings.multiPersonChats.push(multiPersonChat); + groupCreated = true; + } + + // 保存并刷新 + requestSave(); + refreshContactsList(); + refreshChatList(); // 刷新聊天列表以显示新创建的群聊 + closeCharSelectModal(); + + // 提示结果 + const msgs = []; + if (createdContacts.length > 0) msgs.push(`${createdContacts.length} 个联系人`); + if (groupCreated) msgs.push('1 个群聊'); + + if (msgs.length > 0) { + showToast(`导入成功!已创建 ${msgs.join(' 和 ')}`, '✓'); + } else { + showToast('未创建任何内容'); + } + + pendingParseResult = null; +} + +// ========== 角色表格操作 ========== + +/** + * 标记表格为已修改状态 + */ +function markTableAsModified(card) { + if (card.classList.contains('modified')) return; + + card.classList.add('modified'); + + const tip = card.querySelector('.wechat-char-table-modified-tip'); + if (tip) tip.classList.remove('hidden'); + + const saveBtn = card.querySelector('.wechat-char-table-save'); + if (saveBtn) saveBtn.classList.remove('hidden'); +} + +/** + * 清除已修改状态 + */ +function clearTableModified(card) { + card.classList.remove('modified'); + + const tip = card.querySelector('.wechat-char-table-modified-tip'); + if (tip) tip.classList.add('hidden'); + + const saveBtn = card.querySelector('.wechat-char-table-save'); + if (saveBtn) saveBtn.classList.add('hidden'); +} + +/** + * 保存表格修改 + */ +function saveTableChanges(card, tableIdx) { + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[tableIdx]; + if (!table) return; + + // 保存世界观 + const worldViewTextarea = card.querySelector('.wechat-worldview-textarea'); + if (worldViewTextarea) { + table.worldView = worldViewTextarea.value?.trim() || ''; + } + + // 保存角色列表 + const rows = card.querySelectorAll('.wechat-char-table tbody tr'); + const newCharacters = []; + + rows.forEach(row => { + const name = row.querySelector('[data-field="name"]')?.value?.trim() || ''; + const gender = row.querySelector('[data-field="gender"]')?.value?.trim() || ''; + const age = row.querySelector('[data-field="age"]')?.value?.trim() || ''; + // 从按钮的 data-other 属性读取 + const otherBtn = row.querySelector('.wechat-char-other-btn'); + const other = otherBtn?.dataset?.other || ''; + + if (name) { + newCharacters.push({ name, gender, age, other }); + } + }); + + table.characters = newCharacters; + table.lastModified = new Date().toLocaleString('zh-CN'); + + requestSave(); + clearTableModified(card); + + const badge = card.querySelector('.wechat-char-table-badge'); + if (badge) badge.textContent = `${newCharacters.length}个角色`; + + showToast('已保存'); +} + +/** + * 添加新行 + */ +function addTableRow(card) { + const tbody = card.querySelector('.wechat-char-table tbody'); + if (!tbody) return; + + const newIdx = tbody.querySelectorAll('tr').length; + const newRow = document.createElement('tr'); + newRow.dataset.charIdx = newIdx; + newRow.innerHTML = ` + + + + + + + + + + + + + + + + + + + `; + tbody.appendChild(newRow); + newRow.querySelector('.char-name')?.focus(); +} + +/** + * 从表格导入联系人 + */ +function importFromTable(tableIdx) { + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[tableIdx]; + if (!table) return; + + const parseResult = { + isMultiChar: true, + characters: table.characters.map(char => ({ + name: char.name, + gender: char.gender, + age: char.age, + other: char.other, + description: char.other, + originalEntry: char.originalEntry || null + })), + originalCard: { name: table.sourceName || table.name } + }; + + openCharSelectModal(parseResult); +} + +/** + * 从角色表格发起多人群聊 + */ +async function startMultiPersonChat(card, tableIdx) { + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[tableIdx]; + if (!table) return; + + // 获取勾选的角色 + const rowCheckboxes = card.querySelectorAll('.wechat-char-row-check:checked'); + const selectedIndices = Array.from(rowCheckboxes).map(cb => parseInt(cb.dataset.charIdx)); + const selectedChars = selectedIndices.map(idx => table.characters[idx]).filter(Boolean); + + if (selectedChars.length < 2) { + showToast('请至少选择2个角色', '⚠️'); + return; + } + + // 获取世界观 + const worldView = table.worldView || ''; + + // 创建多人群聊 + 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 groupName = '群聊'; + + // 生成白底黑字"群"头像 + const groupAvatar = generateGroupAvatar(); + + const multiPersonChat = { + id: 'mp_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9), + name: groupName, + avatar: groupAvatar, // 白底黑字"群"头像 + type: 'multi-person', // 标记为多人群聊类型 + worldView: worldView, // 保存世界观 + members: selectedChars.map(char => ({ + id: 'mp_member_' + Math.random().toString(36).substring(2, 9), + name: char.name, + gender: char.gender || '', + age: char.age || '', + description: char.other || '' + })), + chatHistory: [], + lastMessage: '', + lastMessageTime: Date.now(), + createdTime: timeStr, + sourceTable: table.name // 记录来源表格 + }; + + // 添加到多人群聊列表 + if (!settings.multiPersonChats) settings.multiPersonChats = []; + settings.multiPersonChats.push(multiPersonChat); + + requestSave(); + showToast(`已创建多人群聊「${groupName}」`); + + // 刷新列表并打开群聊 + const { refreshChatList } = await import('./ui.js'); + refreshChatList(); + + // 打开多人群聊 + const { openMultiPersonChat } = await import('./multi-person-chat.js'); + openMultiPersonChat(settings.multiPersonChats.length - 1); +} + +// ========== 事件绑定 ========== + +/** + * 绑定多人卡导入弹窗事件 + */ +export function bindMultiImportEvents() { + // 关闭弹窗 + document.getElementById('wechat-multi-import-close')?.addEventListener('click', closeMultiImportModal); + document.getElementById('wechat-multi-import-cancel')?.addEventListener('click', closeMultiImportModal); + + // 独立API开关 + document.getElementById('wechat-multi-import-custom-api')?.addEventListener('click', function () { + this.classList.toggle('on'); + const isOn = this.classList.contains('on'); + document.getElementById('wechat-multi-import-api-config')?.classList.toggle('hidden', !isOn); + document.getElementById('wechat-multi-import-global-tip')?.classList.toggle('hidden', isOn); + }); + + // 手动/选择模式切换 + document.getElementById('wechat-multi-import-model-toggle')?.addEventListener('click', function () { + const selectWrapper = document.getElementById('wechat-multi-import-model-select-wrapper'); + const inputWrapper = document.getElementById('wechat-multi-import-model-input-wrapper'); + const isManual = inputWrapper?.style.display === 'none'; + + if (selectWrapper) selectWrapper.style.display = isManual ? 'none' : 'flex'; + if (inputWrapper) inputWrapper.style.display = isManual ? 'flex' : 'none'; + this.textContent = isManual ? '选择' : '手动'; + }); + + // 获取模型列表 + document.getElementById('wechat-multi-import-fetch-model')?.addEventListener('click', async function () { + const apiUrl = document.getElementById('wechat-multi-import-api-url')?.value?.trim(); + const apiKey = document.getElementById('wechat-multi-import-api-key')?.value?.trim(); + + if (!apiUrl) { + showToast('请先填写 API 地址', 'warning'); + return; + } + + this.textContent = '...'; + this.disabled = true; + + try { + const { fetchModelListFromApi } = await import('./ai.js'); + const models = await fetchModelListFromApi(apiUrl, apiKey); + + const select = document.getElementById('wechat-multi-import-model-select'); + if (select && models.length > 0) { + select.innerHTML = '' + + models.map(m => ``).join(''); + showToast(`获取到 ${models.length} 个模型`); + } else { + showToast('未找到可用模型', 'warning'); + } + } catch (err) { + showToast('获取失败: ' + err.message, 'error'); + } finally { + this.textContent = '获取'; + this.disabled = false; + } + }); + + // 测试连接 + document.getElementById('wechat-multi-import-test')?.addEventListener('click', async function () { + const config = getMultiImportApiConfig(); + if (!config.apiUrl || !config.model) { + showToast('请填写完整配置', 'warning'); + return; + } + + this.textContent = '测试中...'; + this.disabled = true; + + try { + const { testConnection } = await import('./ai.js'); + await testConnection(config.apiUrl, config.apiKey, config.model); + showToast('连接成功'); + } catch (err) { + showToast('连接失败: ' + err.message, 'error'); + } finally { + this.textContent = '测试连接'; + this.disabled = false; + } + }); + + // 选择 PNG 文件 + document.getElementById('wechat-multi-import-select-png')?.addEventListener('click', () => { + selectFile('.png', handleFileSelected); + }); + + // 选择 JSON 文件 + document.getElementById('wechat-multi-import-select-json')?.addEventListener('click', () => { + selectFile('.json', handleFileSelected); + }); + + // 开始解析 + document.getElementById('wechat-multi-import-start')?.addEventListener('click', startMultiImportParse); +} + +/** + * 绑定角色选择弹窗事件 + */ +export function bindCharSelectEvents() { + // 关闭弹窗 + document.getElementById('wechat-char-select-close')?.addEventListener('click', closeCharSelectModal); + document.getElementById('wechat-char-select-cancel')?.addEventListener('click', closeCharSelectModal); + + // 确认导入 + document.getElementById('wechat-char-select-confirm')?.addEventListener('click', confirmCharSelectImport); + + // 角色勾选变化(使用事件委托) + document.getElementById('wechat-char-select-list')?.addEventListener('change', (e) => { + if (e.target.classList.contains('wechat-char-select-check')) { + updateCharSelectCount(); + updateCharSelectGroupName(); + } + }); + + // 全选开关 + document.getElementById('wechat-char-select-all')?.addEventListener('change', function () { + const listContainer = document.getElementById('wechat-char-select-list'); + if (listContainer) { + listContainer.style.opacity = this.checked ? '1' : '0.5'; + listContainer.style.pointerEvents = this.checked ? 'auto' : 'none'; + } + if (!this.checked) { + const groupCheckbox = document.getElementById('wechat-char-select-group'); + if (groupCheckbox) groupCheckbox.checked = false; + } + }); + + // 群名输入框标记用户已编辑 + document.getElementById('wechat-char-select-group-name')?.addEventListener('input', function () { + this.dataset.userEdited = 'true'; + }); +} + +/** + * 绑定角色表格事件 + */ +export function bindCharacterTableEvents() { + const container = document.getElementById('wechat-char-tables-container'); + if (!container) return; + + // 输入变化 -> 标记已修改 + container.addEventListener('input', (e) => { + if (e.target.classList.contains('wechat-char-edit-input') || + e.target.classList.contains('wechat-worldview-textarea')) { + const card = e.target.closest('.wechat-char-table-card'); + if (card) markTableAsModified(card); + } + }); + + // 点击事件 + container.addEventListener('click', (e) => { + const card = e.target.closest('.wechat-char-table-card'); + if (!card) return; + + const idx = parseInt(card.dataset.tableIdx); + const settings = getSettings(); + const table = settings.parsedCharacterTables?.[idx]; + if (!table) return; + + // 展开/收起 + if (e.target.closest('.wechat-char-table-header') && + !e.target.closest('.wechat-char-table-delete')) { + table.isExpanded = !table.isExpanded; + requestSave(); + refreshCharacterTablesUI(); + return; + } + + // 删除表格 + if (e.target.closest('.wechat-char-table-delete')) { + if (confirm(`确定删除「${table.name}」吗?`)) { + settings.parsedCharacterTables.splice(idx, 1); + requestSave(); + refreshCharacterTablesUI(); + showToast('已删除'); + } + return; + } + + // 删除行 + if (e.target.closest('.wechat-char-row-delete')) { + const row = e.target.closest('tr'); + if (row) { + row.remove(); + markTableAsModified(card); + } + return; + } + + // 添加行 + if (e.target.closest('.wechat-char-add-row')) { + addTableRow(card); + markTableAsModified(card); + return; + } + + // 保存 + if (e.target.closest('.wechat-char-table-save')) { + saveTableChanges(card, idx); + return; + } + + // 导入为联系人 + if (e.target.closest('.wechat-char-table-import')) { + if (card.classList.contains('modified')) { + if (confirm('有未保存的修改,是否先保存?')) { + saveTableChanges(card, idx); + } + } + importFromTable(idx); + return; + } + + // 全选/取消全选勾选框 + if (e.target.classList.contains('wechat-char-select-all-check')) { + const isChecked = e.target.checked; + const rowCheckboxes = card.querySelectorAll('.wechat-char-row-check'); + rowCheckboxes.forEach(cb => { + cb.checked = isChecked; + }); + return; + } + + // 发起群聊按钮 + if (e.target.closest('.wechat-char-table-start-chat')) { + // 如果有未保存的修改,先保存 + if (card.classList.contains('modified')) { + saveTableChanges(card, idx); + } + startMultiPersonChat(card, idx); + return; + } + + // "其它"按钮点击 + if (e.target.closest('.wechat-char-other-btn')) { + const btn = e.target.closest('.wechat-char-other-btn'); + const charIdx = parseInt(btn.dataset.charIdx); + const otherText = btn.dataset.other || ''; + openCharOtherEditModal(idx, charIdx, otherText, btn); + return; + } + }); + + // 行勾选框变化时,更新全选框状态 + container.addEventListener('change', (e) => { + if (e.target.classList.contains('wechat-char-row-check')) { + const card = e.target.closest('.wechat-char-table-card'); + if (!card) return; + + const allCheckbox = card.querySelector('.wechat-char-select-all-check'); + const rowCheckboxes = card.querySelectorAll('.wechat-char-row-check'); + const checkedCount = Array.from(rowCheckboxes).filter(cb => cb.checked).length; + + if (allCheckbox) { + allCheckbox.checked = checkedCount === rowCheckboxes.length; + allCheckbox.indeterminate = checkedCount > 0 && checkedCount < rowCheckboxes.length; + } + } + }); +} + +/** + * 初始化多人卡导入模块 + */ +export function initMultiCharImport() { + bindMultiImportEvents(); + bindCharSelectEvents(); + bindCharacterTableEvents(); + bindCharOtherEditEvents(); + refreshCharacterTablesUI(); +} diff --git a/multi-person-chat.js b/multi-person-chat.js new file mode 100644 index 0000000..e2dab47 --- /dev/null +++ b/multi-person-chat.js @@ -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 += `
${timeLabel}
`; + } + } + lastTimestamp = msgTimestamp; + + if (msg.role === 'user') { + // 用户消息:右对齐,有气泡 + html += ` +
+
+
${escapeHtml(msg.content)}
+
+
+ `; + } else { + // 角色消息:无头像,名字+气泡,左对齐 + const charName = msg.characterName || '未知'; + html += ` +
+
+
${escapeHtml(charName)}
+
${escapeHtml(msg.content)}
+
+
+ `; + } + }); + + 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 = ` +
+
${escapeHtml(content)}
+
+ `; + } else { + messageDiv.className = 'wechat-message wechat-mp-message'; + messageDiv.innerHTML = ` +
+
${escapeHtml(characterName || '未知')}
+
${escapeHtml(content)}
+
+ `; + } + + 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 = ` +
+
${escapeHtml(characterName || '...')}
+
+ + + +
+
+ `; + + 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; +} diff --git a/phone-html.js b/phone-html.js index fea42bb..db73ff3 100644 --- a/phone-html.js +++ b/phone-html.js @@ -154,6 +154,13 @@ export function generatePhoneHTML() {
导入角色卡 (JSON)
+
+
+ +
+
导入多人卡
+ +
@@ -171,6 +178,14 @@ export function generatePhoneHTML() { 撤回消息 +
聊天背景 @@ -774,6 +789,14 @@ function generateServicePageHTML(settings) {
Meme表情
语音API
+
多人卡表格
+
+ +
@@ -1039,6 +1062,71 @@ function generateModalsHTML(settings) {
+ + + `; } @@ -1512,6 +1600,161 @@ function generateGiftPageHTML() { + + + + + + + + +