diff --git a/ai.js b/ai.js index 6a82cc3..4bfe239 100644 --- a/ai.js +++ b/ai.js @@ -971,6 +971,131 @@ ${voiceCallPrompt}`; return data.choices?.[0]?.message?.content || '...'; } +// 实时语音通话中调用 AI(纯文本输出,不带任何格式标记) +export async function callRealVoiceAI(contact, userMessage, callMessages = [], initiator = 'user') { + // 获取 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 realVoicePrompt = `你正在和用户进行实时语音通话。 + +【重要输出规则】 +- 只输出你说出口的话,不要有任何其他内容 +- 禁止使用小括号描述语气、动作、情绪 +- 禁止使用方括号、尖括号等任何标记 +- 禁止添加旁白、说明、注释 +- 一次输出完整的回复,不需要分段 + +正确示例: +喂?在呢在呢,怎么突然打电话过来啦,是不是想我了? + +错误示例(禁止): +喂?(好奇地)在呢~[开心] + +【通话规则】 +- 像真人打电话一样自然交流 +- 符合你的角色设定和性格 +- 积极与用户互动,根据话题自然展开对话 +- 可以说的比较多,像真人聊天`; + + // 构建系统提示词 + const baseSystemPrompt = buildSystemPrompt(contact, { allowStickers: false, allowMusicShare: false, allowCallRequests: false }); + const systemPrompt = `${baseSystemPrompt} + +【当前场景:实时语音通话中】 +${realVoicePrompt}`; + + // 构建消息 + const messages = [{ role: 'system', content: systemPrompt }]; + + // 添加聊天历史 + const chatHistory = contact.chatHistory || []; + chatHistory.forEach(msg => { + if (msg.isRecalled) { + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: '[用户撤回了一条消息]' + }); + return; + } + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + }); + }); + + // 添加通话标记 + if (initiator === 'ai') { + messages.push({ role: 'assistant', content: '[你主动拨打了实时语音,用户已接听]' }); + } else { + messages.push({ role: 'user', content: '[用户发起了实时语音,你已接听]' }); + } + + // 添加通话历史 + callMessages.forEach(msg => { + messages.push({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + }); + }); + + // 添加当前消息 + messages.push({ role: 'user', content: userMessage }); + + const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions'; + + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetchWithRetry( + chatUrl, + { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: apiModel, + messages: messages, + temperature: 1, + max_tokens: 500 + }) + }, + { maxRetries: 3 } + ); + + if (!response.ok) { + throw new Error(await formatApiError(response, {})); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content || '...'; +} + // 视频通话中调用 AI(使用专门的视频通话提示词,包含场景描述) // initiator: 'user' 表示用户打给AI,'ai' 表示AI打给用户 export async function callVideoAI(contact, userMessage, callMessages = [], initiator = 'user') { diff --git a/audio-storage.js b/audio-storage.js new file mode 100644 index 0000000..a79c7a1 --- /dev/null +++ b/audio-storage.js @@ -0,0 +1,277 @@ +/** + * 语音存储模块 - 使用 IndexedDB 存储语音回放 + */ + +const DB_NAME = 'WechatVoiceStorage'; +const DB_VERSION = 1; +const STORE_NAME = 'voiceRecordings'; + +let db = null; + +/** + * 初始化数据库 + */ +export async function initAudioDB() { + if (db) return db; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.error('[可乐] IndexedDB 打开失败:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + db = request.result; + console.log('[可乐] IndexedDB 初始化成功'); + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const database = event.target.result; + + // 创建存储对象 + if (!database.objectStoreNames.contains(STORE_NAME)) { + const store = database.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + // 索引:按联系人和通话记录查询 + store.createIndex('contactIndex', 'contactIndex', { unique: false }); + store.createIndex('callTimestamp', 'callTimestamp', { unique: false }); + console.log('[可乐] IndexedDB 存储结构创建成功'); + } + }; + }); +} + +/** + * 保存语音记录 + * @param {Object} voiceData - 语音数据 + * @param {number} voiceData.contactIndex - 联系人索引 + * @param {number} voiceData.callTimestamp - 通话时间戳 + * @param {string} voiceData.text - 语音对应的文字 + * @param {Blob} voiceData.audioBlob - 音频数据 + * @param {number} voiceData.duration - 时长(秒) + * @returns {Promise} 保存的记录 ID + */ +export async function saveVoiceRecording(voiceData) { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + const record = { + contactIndex: voiceData.contactIndex, + callTimestamp: voiceData.callTimestamp, + text: voiceData.text, + audioBlob: voiceData.audioBlob, + duration: voiceData.duration, + savedAt: Date.now() + }; + + const request = store.add(record); + + request.onsuccess = () => { + console.log('[可乐] 语音保存成功, ID:', request.result); + resolve(request.result); + }; + + request.onerror = () => { + console.error('[可乐] 语音保存失败:', request.error); + reject(request.error); + }; + }); +} + +/** + * 批量保存语音记录 + * @param {Array} voiceDataList - 语音数据数组 + * @returns {Promise} 保存的记录 ID 数组 + */ +export async function saveVoiceRecordings(voiceDataList) { + const ids = []; + for (const voiceData of voiceDataList) { + const id = await saveVoiceRecording(voiceData); + ids.push(id); + } + return ids; +} + +/** + * 获取指定通话的所有语音记录 + * @param {number} contactIndex - 联系人索引 + * @param {number} callTimestamp - 通话时间戳 + * @returns {Promise} 语音记录数组 + */ +export async function getVoiceRecordingsByCall(contactIndex, callTimestamp) { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const index = store.index('callTimestamp'); + + const request = index.getAll(callTimestamp); + + request.onsuccess = () => { + // 过滤出指定联系人的记录 + const records = request.result.filter(r => r.contactIndex === contactIndex); + resolve(records); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 获取指定联系人的所有语音记录 + * @param {number} contactIndex - 联系人索引 + * @returns {Promise} 语音记录数组 + */ +export async function getVoiceRecordingsByContact(contactIndex) { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const index = store.index('contactIndex'); + + const request = index.getAll(contactIndex); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 获取单条语音记录 + * @param {number} id - 记录 ID + * @returns {Promise} 语音记录 + */ +export async function getVoiceRecording(id) { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + + const request = store.get(id); + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 删除语音记录 + * @param {number} id - 记录 ID + */ +export async function deleteVoiceRecording(id) { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + const request = store.delete(id); + + request.onsuccess = () => { + console.log('[可乐] 语音删除成功, ID:', id); + resolve(); + }; + + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * 删除指定通话的所有语音记录 + * @param {number} contactIndex - 联系人索引 + * @param {number} callTimestamp - 通话时间戳 + */ +export async function deleteVoiceRecordingsByCall(contactIndex, callTimestamp) { + const records = await getVoiceRecordingsByCall(contactIndex, callTimestamp); + for (const record of records) { + await deleteVoiceRecording(record.id); + } +} + +/** + * 播放语音记录 + * @param {number} id - 记录 ID + * @returns {Promise} 音频元素 + */ +export async function playVoiceRecording(id) { + const record = await getVoiceRecording(id); + if (!record || !record.audioBlob) { + throw new Error('语音记录不存在'); + } + + const audioUrl = URL.createObjectURL(record.audioBlob); + const audio = new Audio(audioUrl); + + return new Promise((resolve, reject) => { + audio.onended = () => { + URL.revokeObjectURL(audioUrl); + resolve(audio); + }; + + audio.onerror = (err) => { + URL.revokeObjectURL(audioUrl); + reject(err); + }; + + audio.play().catch(reject); + }); +} + +/** + * 获取存储统计信息 + * @returns {Promise} 统计信息 + */ +export async function getStorageStats() { + await initAudioDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + + const countRequest = store.count(); + const allRequest = store.getAll(); + + let count = 0; + let totalSize = 0; + + countRequest.onsuccess = () => { + count = countRequest.result; + }; + + allRequest.onsuccess = () => { + const records = allRequest.result; + totalSize = records.reduce((sum, r) => sum + (r.audioBlob?.size || 0), 0); + resolve({ + count, + totalSize, + totalSizeMB: (totalSize / 1024 / 1024).toFixed(2) + }); + }; + + transaction.onerror = () => { + reject(transaction.error); + }; + }); +} diff --git a/chat-func-panel.js b/chat-func-panel.js index 3264dbb..ea61aa9 100644 --- a/chat-func-panel.js +++ b/chat-func-panel.js @@ -8,6 +8,7 @@ import { sendMessage, sendPhotoMessage, sendBatchMessages, appendMusicCardMessag import { isInGroupChat, sendGroupMessage, sendGroupPhotoMessage, sendGroupBatchMessages, getCurrentGroupIndex, appendGroupMessage, showGroupTypingIndicator, hideGroupTypingIndicator, callGroupAI, enforceGroupChatMemberLimit, appendGroupMusicCardMessage } from './group-chat.js'; import { startVoiceCall } from './voice-call.js'; import { startVideoCall } from './video-call.js'; +import { startRealVoiceCall } from './real-voice-call.js'; import { showMusicPanel, initMusicEvents } from './music.js'; import { showRedPacketPage } from './red-packet.js'; import { showTransferPage } from './transfer.js'; @@ -656,6 +657,14 @@ function handleFuncItemClick(func) { hideFuncPanel(); startVideoCall(); return; + case 'realvoice': + hideFuncPanel(); + if (isInGroupChat()) { + showToast('群聊暂不支持实时语音', 'info'); + return; + } + startRealVoiceCall(); + return; case 'music': hideFuncPanel(); showMusicPanel(); diff --git a/chat.js b/chat.js index 0f17154..159a77b 100644 --- a/chat.js +++ b/chat.js @@ -952,6 +952,71 @@ export function renderChatHistory(contact, chatHistory, indexOffset = 0) { return; } + // 检查是否是实时语音通话记录消息 + const realVoiceCallRecordMatch = (msg.content || '').match(/^\[实时语音[::](.+?)\]$/); + if (msg.isRealVoice || realVoiceCallRecordMatch) { + const callInfo = realVoiceCallRecordMatch ? realVoiceCallRecordMatch[1] : '00:00'; + const isDuration = /^\d{2}:\d{2}$/.test(callInfo); + const isCancelled = callInfo === '已取消'; + const isRejected = callInfo === '已拒绝' || callInfo === '对方已拒绝'; + const isTimeout = callInfo === '对方已取消'; + + // 麦克风图标 + const micIconSVG = ` + + + + + `; + + let realVoiceCallRecordHTML; + if (isDuration) { + // 已接通:显示实时语音时长 + realVoiceCallRecordHTML = ` +
+ ${micIconSVG} + 实时语音 ${callInfo} +
+ `; + } else if (isCancelled) { + realVoiceCallRecordHTML = ` +
+ ${micIconSVG} + 已取消 +
+ `; + } else if (isRejected) { + realVoiceCallRecordHTML = ` +
+ ${micIconSVG} + ${callInfo} +
+ `; + } else if (isTimeout) { + realVoiceCallRecordHTML = ` +
+ ${micIconSVG} + 对方已取消 +
+ `; + } else { + realVoiceCallRecordHTML = ` +
+ ${micIconSVG} + ${escapeHtml(callInfo)} +
+ `; + } + + if (msg.role === 'user') { + html += `
${getUserAvatarHTML()}
${realVoiceCallRecordHTML}
`; + } else { + html += `
${avatarContent}
${realVoiceCallRecordHTML}
`; + } + lastTimestamp = msgTimestamp; + return; + } + // 检查是否是视频通话记录消息 const videoCallRecordMatch = (msg.content || '').match(/^\[视频通话[::](.+?)\]$/); if (msg.isVideoCallRecord || videoCallRecordMatch) { @@ -1534,6 +1599,11 @@ export function appendMessage(role, content, contact, isVoice = false, quote = n const messageDiv = document.createElement('div'); messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`; + // 计算消息在chatHistory中的索引 + const msgIndex = contact?.chatHistory ? contact.chatHistory.length - 1 : -1; + messageDiv.dataset.msgIndex = msgIndex; + messageDiv.dataset.msgRole = role === 'user' ? 'user' : 'assistant'; + const firstChar = contact?.name ? contact.name.charAt(0) : '?'; const avatarContent = role === 'user' ? getUserAvatarHTML() diff --git a/config.js b/config.js index 219073f..d00a9a7 100644 --- a/config.js +++ b/config.js @@ -120,18 +120,24 @@ export function getMemePromptTemplate() { return `##【必须使用】表情包功能 【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包! -使用规则: -- 格式:表情名称 -- 只需要填写表情名称,不需要填写文件ID和扩展名 -- 只能从下面列表选择,不能编造名称 +★★★ 表情包标签格式(必须严格遵守)★★★ +格式:表情名称 +- 必须是成对标签:开始标签和结束标签缺一不可 +- 表情名称必须从下面列表选择,不能编造 +- 不需要填写文件ID和扩展名,只填表情名称 -【绝对禁止 - 最重要的规则!】 +【绝对禁止 - 最重要的规则!违反会导致显示错误!】 标签前后【绝对不能】有任何其他文字!必须用 ||| 分隔! -× 错误:好想你xxx ← 绝对禁止!标签和文字混在一起! -× 错误:xxx哈哈 ← 绝对禁止!标签后面有文字! -× 错误:我很开心xxx你呢 ← 绝对禁止!标签夹在文字中间! -√ 正确:好想你|||xxx ← 用|||分开,标签独立! -√ 正确:xxx|||哈哈哈 ← 标签独立一条! + +× 致命错误:好想你xxx ← 禁止!标签和文字粘在一起! +× 致命错误:xxx哈哈 ← 禁止!标签后面有文字! +× 致命错误:我很开心xxx你呢 ← 禁止!标签夹在文字中间! +× 致命错误:xxx ← 禁止!缺少结束标签! +× 致命错误:xxx ← 禁止!缺少开始标签! + +√ 正确写法:好想你|||小狗摇尾巴 ← 用|||分开! +√ 正确写法:喜欢你|||我真的好喜欢你 ← 标签独立! +√ 正确写法:哈哈|||小熊跳舞|||你太搞笑了 可用表情包列表: [ @@ -143,7 +149,7 @@ ${displayNames.join('\n')} 哈哈哈笑死|||小熊跳舞|||你太搞笑了 喜欢你|||我真的好喜欢你 -记住:表情包让聊天更生动,【必须】经常使用!但标签必须独立!`; +★重要★:必须成对出现!标签必须用|||与文字分开!`; } // 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate() @@ -227,6 +233,24 @@ export const defaultSettings = { groupSelectedModel: '', groupModelList: [], + // ========== 语音功能 API 配置 ========== + // STT (语音转文字) + sttApiUrl: '', + sttApiKey: '', + sttModel: '', + + // TTS (文字转语音) + ttsApiUrl: '', + ttsApiKey: '', + ttsModel: '', // 模型 + ttsVoice: '', // 音色 + ttsSpeed: 1, // 语速 + ttsEmotion: '默认', // 情感 + ttsProxyUrl: '', // TTS 代理 URL(用于解决 CORS 问题,如 MiniMax) + + // 实时语音通话开关 + realVoiceEnabled: true, + // 上下文设置 contextEnabled: false, contextLevel: 5, diff --git a/main.js b/main.js index e309898..a103a93 100644 --- a/main.js +++ b/main.js @@ -37,6 +37,8 @@ import { initGroupRedPacket } from './group-red-packet.js'; import { initGiftEvents } from './gift.js'; import { initCropper } from './cropper.js'; import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js'; +import { testSttApi, testTtsApi } from './voice-api.js'; +import { getVoiceRecordingsByContact, deleteVoiceRecording, playVoiceRecording } from './audio-storage.js'; // ========== 历史记录功能 ========== let currentHistoryTab = 'listen'; @@ -137,6 +139,12 @@ function renderHistoryContent(contact, tabType) { return; } + // 语音回放使用专门的渲染函数 + if (tabType === 'playback') { + renderVoicePlaybackContent(contact); + return; + } + const context = window.SillyTavern?.getContext?.() || {}; const userName = context.name1 || '用户'; @@ -221,6 +229,130 @@ function renderHistoryContent(contact, tabType) { }); } +// 渲染语音回放内容 +async function renderVoicePlaybackContent(contact) { + const contentEl = document.getElementById('wechat-history-content'); + if (!contentEl) return; + + const contactIndex = currentHistoryContactIndex; + if (contactIndex < 0) { + contentEl.innerHTML = '
📭
请先选择联系人
'; + return; + } + + // 显示加载状态 + contentEl.innerHTML = '
加载中...
'; + + try { + const recordings = await getVoiceRecordingsByContact(contactIndex); + + if (!recordings || recordings.length === 0) { + contentEl.innerHTML = ` +
+
+ + + + +
+
暂无语音回放记录
+
实时语音通话结束后可选择保存语音
+
+ `; + return; + } + + // 按保存时间倒序排列 + const sortedRecordings = [...recordings].sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0)); + + let html = '
'; + + for (const recording of sortedRecordings) { + const savedTime = recording.savedAt ? new Date(recording.savedAt).toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) : '未知时间'; + + const durationSec = Math.round(recording.duration || 0); + const durationStr = durationSec > 0 ? `${durationSec}"` : '?秒'; + + html += ` +
+
+ ${escapeHtml(savedTime)} +
+ ${durationStr} + +
+
+
+
${escapeHtml(recording.text || '')}
+ +
+
+ `; + } + + html += '
'; + contentEl.innerHTML = html; + + // 绑定播放按钮事件 + contentEl.querySelectorAll('.wechat-voice-playback-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = parseInt(btn.dataset.id); + + try { + btn.disabled = true; + btn.innerHTML = ''; + + await playVoiceRecording(id); + } catch (err) { + console.error('[可乐] 播放语音失败:', err); + showToast('播放失败', '⚠️'); + } finally { + btn.disabled = false; + btn.innerHTML = ''; + } + }); + }); + + // 绑定删除按钮事件 + contentEl.querySelectorAll('.wechat-voice-playback-delete').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = parseInt(btn.dataset.id); + + if (confirm('确定要删除这条语音吗?')) { + try { + await deleteVoiceRecording(id); + showToast('已删除', '✓'); + // 重新渲染 + renderVoicePlaybackContent(contact); + } catch (err) { + console.error('[可乐] 删除语音失败:', err); + showToast('删除失败', '⚠️'); + } + } + }); + }); + + } catch (err) { + console.error('[可乐] 加载语音记录失败:', err); + contentEl.innerHTML = ` +
+
⚠️
+
加载失败
+
${escapeHtml(err.message || '')}
+
+ `; + } +} + function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); @@ -1679,6 +1811,13 @@ function bindEvents() { return; } + if (service === 'voice-api') { + allPanels.filter(p => p !== 'wechat-voice-api-panel').forEach(p => document.getElementById(p)?.classList.add('hidden')); + const panel = document.getElementById('wechat-voice-api-panel'); + panel?.classList.toggle('hidden'); + return; + } + const label = item.querySelector('span')?.textContent || '该'; showToast(`"${label}" 功能开发中...`, 'info'); }); @@ -2142,6 +2281,140 @@ function bindEvents() { } }); + // ===== 语音 API 面板事件 ===== + // 关闭按钮 + document.getElementById('wechat-voice-api-close')?.addEventListener('click', () => { + document.getElementById('wechat-voice-api-panel')?.classList.add('hidden'); + }); + + // STT 密钥可见性切换 + document.getElementById('wechat-stt-key-toggle')?.addEventListener('click', () => { + const keyInput = document.getElementById('wechat-stt-api-key'); + if (keyInput) { + keyInput.type = keyInput.type === 'password' ? 'text' : 'password'; + } + }); + + // TTS 密钥可见性切换 + document.getElementById('wechat-tts-key-toggle')?.addEventListener('click', () => { + const keyInput = document.getElementById('wechat-tts-api-key'); + if (keyInput) { + keyInput.type = keyInput.type === 'password' ? 'text' : 'password'; + } + }); + + // 测试 STT API + document.getElementById('wechat-voice-api-test-stt')?.addEventListener('click', async () => { + const btn = document.getElementById('wechat-voice-api-test-stt'); + const originalText = btn?.textContent; + if (btn) { + btn.textContent = '测试中...'; + btn.disabled = true; + } + + try { + // 先保存当前配置 + const settings = getSettings(); + settings.sttApiUrl = document.getElementById('wechat-stt-api-url')?.value?.trim() || ''; + settings.sttApiKey = document.getElementById('wechat-stt-api-key')?.value?.trim() || ''; + settings.sttModel = document.getElementById('wechat-stt-model')?.value?.trim() || ''; + + await testSttApi(); + showToast('STT 连接成功!', '✓'); + } catch (err) { + console.error('[可乐] STT 测试失败:', err); + showToast('STT 测试失败: ' + err.message, '⚠️'); + } finally { + if (btn) { + btn.textContent = originalText; + btn.disabled = false; + } + } + }); + + // 测试 TTS API + document.getElementById('wechat-voice-api-test-tts')?.addEventListener('click', async () => { + const btn = document.getElementById('wechat-voice-api-test-tts'); + const originalText = btn?.textContent; + if (btn) { + btn.textContent = '测试中...'; + btn.disabled = true; + } + + try { + // 先保存当前配置 + const settings = getSettings(); + settings.ttsApiUrl = document.getElementById('wechat-tts-api-url')?.value?.trim() || ''; + settings.ttsApiKey = document.getElementById('wechat-tts-api-key')?.value?.trim() || ''; + settings.ttsModel = document.getElementById('wechat-tts-model')?.value?.trim() || ''; + settings.ttsVoice = document.getElementById('wechat-tts-voice')?.value?.trim() || ''; + settings.ttsSpeed = parseFloat(document.getElementById('wechat-tts-speed')?.value) || 1; + settings.ttsEmotion = document.getElementById('wechat-tts-emotion')?.value?.trim() || '默认'; + settings.ttsProxyUrl = document.getElementById('wechat-tts-proxy-url')?.value?.trim() || ''; + + const audioBlob = await testTtsApi(); + console.log('[可乐] TTS 测试返回音频:', { + size: audioBlob?.size, + type: audioBlob?.type + }); + + if (!audioBlob || audioBlob.size < 100) { + throw new Error('返回的音频数据无效'); + } + + // 播放测试音频 + const audioUrl = URL.createObjectURL(audioBlob); + const audio = new Audio(audioUrl); + audio.volume = 1.0; + + await new Promise((resolve, reject) => { + audio.onended = () => { + URL.revokeObjectURL(audioUrl); + resolve(); + }; + audio.onerror = (e) => { + URL.revokeObjectURL(audioUrl); + reject(new Error('音频播放失败')); + }; + audio.play().then(() => { + console.log('[可乐] 测试音频开始播放'); + }).catch(reject); + }); + + showToast('TTS 测试成功!正在播放', '✓'); + } catch (err) { + console.error('[可乐] TTS 测试失败:', err); + showToast('TTS 测试失败: ' + err.message, '⚠️'); + } finally { + if (btn) { + btn.textContent = originalText; + btn.disabled = false; + } + } + }); + + // 保存语音 API 配置 + document.getElementById('wechat-voice-api-save')?.addEventListener('click', () => { + const settings = getSettings(); + + // STT 配置 + settings.sttApiUrl = document.getElementById('wechat-stt-api-url')?.value?.trim() || ''; + settings.sttApiKey = document.getElementById('wechat-stt-api-key')?.value?.trim() || ''; + settings.sttModel = document.getElementById('wechat-stt-model')?.value?.trim() || ''; + + // TTS 配置 + settings.ttsApiUrl = document.getElementById('wechat-tts-api-url')?.value?.trim() || ''; + settings.ttsApiKey = document.getElementById('wechat-tts-api-key')?.value?.trim() || ''; + settings.ttsModel = document.getElementById('wechat-tts-model')?.value?.trim() || ''; + settings.ttsVoice = document.getElementById('wechat-tts-voice')?.value?.trim() || ''; + settings.ttsSpeed = parseFloat(document.getElementById('wechat-tts-speed')?.value) || 1; + settings.ttsEmotion = document.getElementById('wechat-tts-emotion')?.value?.trim() || '默认'; + settings.ttsProxyUrl = document.getElementById('wechat-tts-proxy-url')?.value?.trim() || ''; + + requestSave(); + showToast('语音 API 配置已保存', '✓'); + }); + // 绑定联系人点击 refreshContactsList(); } diff --git a/message-menu.js b/message-menu.js index 25a25c0..deae05f 100644 --- a/message-menu.js +++ b/message-menu.js @@ -688,6 +688,14 @@ export function bindMessageBubbleEvents(container) { // 获取真实的消息索引(排除时间标签等) function getRealMsgIndex(container, msgElement) { + // 优先从元素属性获取(新消息会有这个属性) + if (msgElement?.dataset?.msgIndex !== undefined) { + const idx = parseInt(msgElement.dataset.msgIndex); + if (!isNaN(idx) && idx >= 0) { + return idx; + } + } + const settings = getSettings(); const contact = settings.contacts[currentChatIndex]; if (!contact || !contact.chatHistory) return -1; @@ -699,7 +707,7 @@ function getRealMsgIndex(container, msgElement) { if (visualIndex < 0) return -1; // 需要计算真实索引(chatHistory中可能包含marker消息和撤回消息) - // 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算 + // 注意:包含 ||| 或 的消息在渲染时会被拆分成多条可视消息,需要正确计算 let realIndex = -1; let visualCount = 0; @@ -712,9 +720,10 @@ function getRealMsgIndex(container, msgElement) { let visualMsgCount = 1; const content = msg.content || ''; const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic; - if (!isSpecial && content.indexOf('|||') >= 0) { - // 按 ||| 分割后有多少个非空部分 - const parts = content.split('|||').map(p => p.trim()).filter(p => p); + // 检查是否包含 ||| 或 标签(这些会导致消息被分割显示) + if (!isSpecial && (content.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(content))) { + // 使用 splitAIMessages 计算实际分割数量 + const parts = splitAIMessages(content).filter(p => p && p.trim()); visualMsgCount = parts.length || 1; } diff --git a/phone-html.js b/phone-html.js index eab2835..c51030c 100644 --- a/phone-html.js +++ b/phone-html.js @@ -228,15 +228,16 @@ export function generatePhoneHTML() {
照片
语音通话
视频通话
+
实时语音
位置
红包
礼物
转账
-
多条消息
+
多条消息
语音输入
一起听
音乐
@@ -292,6 +293,7 @@ export function generatePhoneHTML() { ${generateModalsHTML(settings)} ${generateVoiceCallPageHTML()} ${generateVideoCallPageHTML()} + ${generateRealVoiceCallPageHTML()} ${generateMusicPanelHTML()} ${generateListenTogetherHTML()} ${generateMomentsPageHTML()} @@ -767,6 +769,7 @@ function generateServicePageHTML(settings) {
AI功能
Meme表情
+
语音API
@@ -802,6 +805,77 @@ function generateServicePageHTML(settings) {
+ + `; @@ -944,6 +1018,23 @@ function generateModalsHTML(settings) { + + `; } @@ -969,12 +1060,14 @@ function generateVoiceCallPageHTML() { + + + @@ -1050,12 +1143,14 @@ function generateVideoCallPageHTML() { + + + @@ -1113,6 +1208,82 @@ function generateVideoCallPageHTML() { `; } +// 实时语音通话页面 HTML +function generateRealVoiceCallPageHTML() { + return ` + + + `; +} + // 朋友圈页面 HTML function generateMomentsPageHTML() { return ` @@ -1654,11 +1825,12 @@ function generateHistoryPageHTML() {
- +
+
diff --git a/real-voice-call.js b/real-voice-call.js new file mode 100644 index 0000000..f0db20d --- /dev/null +++ b/real-voice-call.js @@ -0,0 +1,1193 @@ +/** + * 实时语音通话功能 + * 真正的语音交互:用户说话 → STT → AI → TTS → 播放 + */ + +import { getSettings } from './config.js'; +import { currentChatIndex } from './chat.js'; +import { requestSave } from './save-manager.js'; +import { refreshChatList } from './ui.js'; +import { AudioRecorder, speechToText, textToSpeech, playAudio } from './voice-api.js'; +import { showToast } from './toast.js'; +import { saveVoiceRecordings } from './audio-storage.js'; + +// 通话状态 +let callState = { + isActive: false, + isConnected: false, + isMuted: false, + isHangingUp: false, // 是否正在挂断 + startTime: null, + timerInterval: null, + dotsInterval: null, + connectTimeout: null, + contactIndex: -1, + contactName: '', + contactAvatar: '', + messages: [], // 通话消息记录 + contact: null, + initiator: 'user', + rejectedByUser: false, + rejectedByAI: false, + isRecording: false, // 是否正在录音 + isProcessing: false, // 是否正在处理(STT/AI/TTS) + isPlaying: false, // 是否正在播放语音 + recorder: null, // 录音器实例 + currentAudio: null, // 当前播放的音频 + voiceCache: [] // 缓存的 AI 语音 [{text, audioBlob, duration}] +}; + +/** + * 开始实时语音通话 + */ +export function startRealVoiceCall(initiator = 'user', contactIndex = currentChatIndex) { + if (callState.isActive) return; + if (contactIndex < 0) return; + + const settings = getSettings(); + const contact = settings.contacts[contactIndex]; + if (!contact) return; + + // 检查语音 API 是否配置 + if (!settings.sttApiUrl || !settings.sttApiKey) { + alert('请先在设置中配置语音识别 (STT) API'); + return; + } + if (!settings.ttsApiUrl || !settings.ttsApiKey) { + alert('请先在设置中配置语音合成 (TTS) API'); + return; + } + + // 检查浏览器是否支持录音 + if (!AudioRecorder.isSupported()) { + alert('您的浏览器不支持录音功能'); + return; + } + + callState.contactName = contact.name; + callState.contactAvatar = contact.avatar; + callState.contact = contact; + callState.contactIndex = contactIndex; + callState.isActive = true; + callState.isConnected = false; + callState.isMuted = false; + callState.messages = []; + callState.initiator = initiator; + callState.rejectedByUser = false; + callState.rejectedByAI = false; + callState.isRecording = false; + callState.isProcessing = false; + callState.isPlaying = false; + callState.recorder = new AudioRecorder(); + + showCallPage(); + startConnecting(); +} + +/** + * 显示通话页面 + */ +function showCallPage() { + const page = document.getElementById('wechat-real-voice-call-page'); + if (!page) return; + + // 设置头像 + const avatarEl = document.getElementById('wechat-real-voice-call-avatar'); + if (avatarEl) { + const firstChar = callState.contactName ? callState.contactName.charAt(0) : '?'; + if (callState.contactAvatar) { + avatarEl.innerHTML = ``; + } else { + avatarEl.textContent = firstChar; + } + } + + // 设置名称 + const nameEl = document.getElementById('wechat-real-voice-call-name'); + if (nameEl) { + nameEl.textContent = callState.contactName; + } + + // 设置状态 + const statusEl = document.getElementById('wechat-real-voice-call-status'); + if (statusEl) { + if (callState.initiator === 'ai') { + statusEl.textContent = '邀请你实时语音...'; + } else { + statusEl.textContent = '等待对方接受邀请'; + } + statusEl.classList.add('connecting'); + } + + // 重置时间显示 + const timeEl = document.getElementById('wechat-real-voice-call-time'); + if (timeEl) { + timeEl.textContent = '00:00'; + timeEl.classList.add('hidden'); + } + + // 隐藏对话区域 + const chatEl = document.getElementById('wechat-real-voice-call-chat'); + if (chatEl) { + chatEl.classList.add('hidden'); + } + const messagesEl = document.getElementById('wechat-real-voice-call-messages'); + if (messagesEl) { + messagesEl.innerHTML = ''; + } + + // 隐藏按住说话按钮 + const talkBtnArea = document.getElementById('wechat-real-voice-call-talk-area'); + if (talkBtnArea) { + talkBtnArea.classList.add('hidden'); + } + + // 检测是否支持录音 + const supportsRecording = AudioRecorder.isSupported(); + const talkBtn = document.getElementById('wechat-real-voice-call-talk-btn'); + const talkHint = document.querySelector('.wechat-real-voice-call-talk-hint'); + const textInputArea = document.getElementById('wechat-real-voice-call-text-input-area'); + + // 语音按钮:只有支持录音时显示 + if (talkBtn) talkBtn.style.display = supportsRecording ? 'flex' : 'none'; + if (talkHint) talkHint.style.display = supportsRecording ? 'block' : 'none'; + // 文字输入:始终显示,方便用户选择打字或语音 + if (textInputArea) textInputArea.style.display = 'flex'; + + // 根据发起者显示不同的操作按钮 + const incomingActionsEl = document.getElementById('wechat-real-voice-call-incoming-actions'); + const callActionsEl = document.getElementById('wechat-real-voice-call-actions'); + + if (callState.initiator === 'ai') { + if (incomingActionsEl) incomingActionsEl.classList.remove('hidden'); + if (callActionsEl) callActionsEl.classList.add('hidden'); + } else { + if (incomingActionsEl) incomingActionsEl.classList.add('hidden'); + if (callActionsEl) callActionsEl.classList.remove('hidden'); + } + + page.classList.remove('hidden'); + bindCallEvents(); +} + +/** + * 开始连接动画 + */ +async function startConnecting() { + const statusEl = document.getElementById('wechat-real-voice-call-status'); + if (!statusEl) return; + + let dotCount = 0; + clearInterval(callState.dotsInterval); + clearTimeout(callState.connectTimeout); + + const waitingText = callState.initiator === 'ai' ? '邀请你实时语音' : '等待对方接受邀请'; + + callState.dotsInterval = setInterval(() => { + dotCount = (dotCount + 1) % 4; + const dots = '.'.repeat(dotCount); + statusEl.textContent = waitingText + dots; + }, 500); + + if (callState.initiator === 'user') { + // 用户发起:调用AI决策是否接听 + const shouldAnswer = await askAIToAnswerCall(callState.contact); + + if (!callState.isActive) return; + + if (shouldAnswer) { + if (callState.isActive && !callState.isConnected) { + onCallConnected(); + } + } else { + callState.rejectedByAI = true; + hangupCall(); + } + } else { + // AI发起:15秒后超时 + callState.connectTimeout = setTimeout(() => { + if (callState.isActive && !callState.isConnected) { + callState.rejectedByUser = false; + hangupCall(); + } + }, 15000); + } +} + +/** + * AI决定是否接听 + */ +async function askAIToAnswerCall(contact) { + if (!contact) return true; + + try { + const { callAI } = await import('./ai.js'); + + const prompt = `[用户正在给你打实时语音电话,你需要决定是否接听] + +根据你的性格和当前心情决定: +- 如果你想接听,只回复:[接听] +- 如果你不想接听,只回复:[拒接] + +注意:大多数情况下你应该接听,只有特殊情况才拒接。`; + + const response = await callAI(contact, prompt); + const trimmed = (response || '').trim(); + + console.log('[可乐] 实时语音 AI接听决策:', trimmed); + + if (trimmed.includes('[拒接]') || trimmed.includes('拒接')) { + return false; + } + + return true; + } catch (err) { + console.error('[可乐] AI接听决策失败:', err); + return true; + } +} + +/** + * 通话接通 + */ +function onCallConnected() { + callState.isConnected = true; + callState.startTime = Date.now(); + + clearInterval(callState.dotsInterval); + clearTimeout(callState.connectTimeout); + + const statusEl = document.getElementById('wechat-real-voice-call-status'); + if (statusEl) { + statusEl.textContent = '通话中'; + statusEl.classList.remove('connecting'); + } + + // 显示计时器 + const timeEl = document.getElementById('wechat-real-voice-call-time'); + if (timeEl) { + timeEl.classList.remove('hidden'); + } + + // 显示对话区域 + const chatEl = document.getElementById('wechat-real-voice-call-chat'); + if (chatEl) { + chatEl.classList.remove('hidden'); + } + + // 显示按住说话按钮 + const talkBtnArea = document.getElementById('wechat-real-voice-call-talk-area'); + if (talkBtnArea) { + talkBtnArea.classList.remove('hidden'); + } + + // 切换到通话中按钮 + const incomingActionsEl = document.getElementById('wechat-real-voice-call-incoming-actions'); + const callActionsEl = document.getElementById('wechat-real-voice-call-actions'); + if (incomingActionsEl) incomingActionsEl.classList.add('hidden'); + if (callActionsEl) callActionsEl.classList.remove('hidden'); + + startCallTimer(); + + // AI发起的通话,接通后AI先打招呼 + if (callState.initiator === 'ai') { + triggerAIGreeting(); + } +} + +/** + * 开始通话计时 + */ +function startCallTimer() { + clearInterval(callState.timerInterval); + + callState.timerInterval = setInterval(() => { + if (!callState.isConnected || !callState.startTime) return; + + const elapsed = Math.floor((Date.now() - callState.startTime) / 1000); + const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0'); + const seconds = (elapsed % 60).toString().padStart(2, '0'); + + const timeEl = document.getElementById('wechat-real-voice-call-time'); + if (timeEl) { + timeEl.textContent = `${minutes}:${seconds}`; + } + }, 1000); +} + +/** + * AI主动打招呼(AI发起通话时) + */ +async function triggerAIGreeting() { + if (!callState.isConnected || !callState.contact) return; + + updateStatus('AI思考中...'); + + try { + const { callRealVoiceAI } = await import('./ai.js'); + const aiResponse = await callRealVoiceAI( + callState.contact, + '[用户接听了实时语音电话]', + [], + 'ai' + ); + + // 清理回复 + let reply = cleanAIReply(aiResponse); + if (!reply) return; + + // 添加消息记录 + addCallMessage('ai', reply); + + // TTS 合成并播放 + await speakText(reply); + + updateStatus('通话中'); + } catch (err) { + console.error('[可乐] AI打招呼失败:', err); + updateStatus('通话中'); + } +} + +/** + * 清理 AI 回复(移除特殊标签,保留完整内容) + */ +function cleanAIReply(text) { + if (!text) return ''; + + console.log('[可乐] AI原始回复:', text); + + let reply = text.trim(); + + // 移除语音标记 + const voiceMatch = reply.match(/^\[语音[::]\s*(.+?)\]$/); + if (voiceMatch) { + reply = voiceMatch[1]; + } + + // 移除特殊标记 + reply = reply.replace(/\[.*?\]/g, '').trim(); + reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim(); + + // 移除括号描述(中文和英文括号) + reply = reply.replace(/([^)]+)/g, '').trim(); + reply = reply.replace(/\([^)]+\)/g, '').trim(); + + // 如果清理后为空,用原始内容去掉标记 + if (!reply && text.trim()) { + reply = text.trim().replace(/[\[\]()()【】<>]/g, '').trim(); + console.log('[可乐] 清理后为空,恢复内容:', reply); + } + + console.log('[可乐] 最终回复:', reply || '(空)'); + + return reply; +} + +/** + * TTS 合成并播放 + */ +async function speakText(text) { + if (!text || callState.isPlaying) return; + + callState.isPlaying = true; + updateStatus('语音合成中...'); + + try { + console.log('[可乐] 开始TTS合成:', text.substring(0, 50)); + const audioBlob = await textToSpeech(text, callState.contact); + + // 检查音频数据 + console.log('[可乐] TTS返回音频:', { + size: audioBlob?.size, + type: audioBlob?.type + }); + + if (!audioBlob || audioBlob.size < 100) { + console.error('[可乐] TTS返回的音频数据无效'); + updateStatus('语音合成失败'); + return; + } + + updateStatus('对方正在说话...'); + + // 播放音频 + const audioUrl = URL.createObjectURL(audioBlob); + const audio = new Audio(audioUrl); + + // 设置音量 + audio.volume = 1.0; + + let audioDuration = 0; + + await new Promise((resolve, reject) => { + audio.onended = () => { + URL.revokeObjectURL(audioUrl); + resolve(); + }; + audio.onerror = (e) => { + URL.revokeObjectURL(audioUrl); + console.error('[可乐] 音频播放错误:', e); + reject(new Error('音频播放失败')); + }; + audio.oncanplaythrough = () => { + audioDuration = audio.duration; + console.log('[可乐] 音频可以播放,时长:', audioDuration); + }; + + audio.play().then(() => { + console.log('[可乐] 音频开始播放'); + }).catch(err => { + console.error('[可乐] 音频播放被阻止:', err); + reject(err); + }); + }); + + // 播放成功后缓存音频(用于通话结束后选择保存) + callState.voiceCache.push({ + text: text, + audioBlob: audioBlob, + duration: audioDuration || (audioBlob.size / 16000) // 估算时长 + }); + console.log('[可乐] 语音已缓存,当前缓存数量:', callState.voiceCache.length); + + } catch (err) { + console.error('[可乐] TTS 播放失败:', err); + // 显示错误提示 + const errorMsg = err.message || '语音播放失败'; + updateStatus('语音失败'); + showToast('语音合成失败: ' + errorMsg.substring(0, 30), '⚠️'); + await new Promise(r => setTimeout(r, 1500)); + } finally { + callState.isPlaying = false; + if (callState.isConnected) { + updateStatus('通话中'); + } + } +} + +/** + * 开始录音(按住说话) + */ +async function startRecording() { + if (!callState.isConnected || callState.isRecording || callState.isProcessing || callState.isPlaying) { + return; + } + + try { + await callState.recorder.start(); + callState.isRecording = true; + updateTalkButton(true); + updateStatus('正在录音...'); + } catch (err) { + console.error('[可乐] 开始录音失败:', err); + alert(err.message); + } +} + +/** + * 停止录音并处理 + */ +async function stopRecording() { + if (!callState.isRecording) return; + + callState.isRecording = false; + updateTalkButton(false); + + try { + const audioBlob = await callState.recorder.stop(); + + if (audioBlob.size < 1000) { + console.log('[可乐] 录音太短,忽略'); + updateStatus('通话中'); + return; + } + + callState.isProcessing = true; + updateStatus('识别中...'); + + // STT 语音转文字 + const userText = await speechToText(audioBlob); + + if (!userText || !userText.trim()) { + console.log('[可乐] 未识别到语音'); + showToast('未识别到语音内容', 'info'); + updateStatus('通话中'); + callState.isProcessing = false; + return; + } + + console.log('[可乐] 用户说:', userText); + + // 添加用户消息 + addCallMessage('user', userText); + + // 调用 AI(带超时保护,使用实时语音专用函数) + updateStatus('AI思考中...'); + const { callRealVoiceAI } = await import('./ai.js'); + + // 30秒超时 + const aiPromise = callRealVoiceAI( + callState.contact, + userText, + callState.messages.slice(0, -1), + callState.initiator + ); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('AI响应超时')), 30000) + ); + const aiResponse = await Promise.race([aiPromise, timeoutPromise]); + + // 清理回复 + let reply = cleanAIReply(aiResponse); + + callState.isProcessing = false; + + if (!reply) { + updateStatus('通话中'); + return; + } + + // 添加 AI 消息 + addCallMessage('ai', reply); + + // TTS 并播放 + await speakText(reply); + + // 检查是否要挂断 + if (detectHangupIntent(reply)) { + setTimeout(() => { + if (callState.isConnected) { + hangupCall(); + } + }, 1500); + } + + } catch (err) { + console.error('[可乐] 语音处理失败:', err); + callState.isProcessing = false; + updateStatus('通话中'); + // 显示具体错误 + const errorMsg = err.message || '处理失败'; + showToast('语音处理失败: ' + errorMsg.substring(0, 30), '⚠️'); + } +} + +/** + * 取消录音 + */ +function cancelRecording() { + if (callState.recorder) { + callState.recorder.cancel(); + } + callState.isRecording = false; + updateTalkButton(false); + updateStatus('通话中'); +} + +/** + * 处理文字输入(不支持录音时的替代方案) + */ +async function processUserTextInput(userText) { + if (!callState.isConnected || callState.isProcessing || callState.isPlaying) { + return; + } + + try { + console.log('[可乐] 用户输入:', userText); + + // 添加用户消息 + addCallMessage('user', userText); + + callState.isProcessing = true; + + // 调用 AI(带超时保护) + updateStatus('AI思考中...'); + const { callRealVoiceAI } = await import('./ai.js'); + + // 30秒超时 + const aiPromise = callRealVoiceAI( + callState.contact, + userText, + callState.messages.slice(0, -1), + callState.initiator + ); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('AI响应超时')), 30000) + ); + const aiResponse = await Promise.race([aiPromise, timeoutPromise]); + + // 清理回复 + let reply = cleanAIReply(aiResponse); + + callState.isProcessing = false; + + if (!reply) { + updateStatus('通话中'); + return; + } + + // 添加 AI 消息 + addCallMessage('ai', reply); + + // TTS 并播放 + await speakText(reply); + + // 检查是否要挂断 + if (detectHangupIntent(reply)) { + setTimeout(() => { + if (callState.isConnected) { + hangupCall(); + } + }, 1500); + } + + } catch (err) { + console.error('[可乐] 文字处理失败:', err); + callState.isProcessing = false; + updateStatus('通话中'); + const errorMsg = err.message || '处理失败'; + showToast('处理失败: ' + errorMsg.substring(0, 30), '⚠️'); + } +} + +/** + * 检测挂断意图 + */ +function detectHangupIntent(text) { + if (!text) return false; + const hangupPatterns = [ + /我(先)?挂了/, + /那我挂了/, + /先挂(了)?啊?/, + /挂了(啊|哈|呀|哦)?$/, + /拜拜.*挂/, + /再见.*挂/ + ]; + return hangupPatterns.some(pattern => pattern.test(text)); +} + +/** + * 更新状态显示 + */ +function updateStatus(text) { + const statusEl = document.getElementById('wechat-real-voice-call-status'); + if (statusEl) { + statusEl.textContent = text; + } +} + +/** + * 更新说话按钮状态 + */ +function updateTalkButton(isRecording) { + const btn = document.getElementById('wechat-real-voice-call-talk-btn'); + if (btn) { + if (isRecording) { + btn.classList.add('recording'); + btn.textContent = '点击 发送'; + } else { + btn.classList.remove('recording'); + btn.textContent = '点击 说话'; + } + } +} + +/** + * 添加通话消息 + */ +function addCallMessage(role, content) { + const messagesEl = document.getElementById('wechat-real-voice-call-messages'); + if (!messagesEl) return; + + callState.messages.push({ role, content }); + + const msgDiv = document.createElement('div'); + msgDiv.className = `wechat-real-voice-call-msg ${role} fade-in`; + + // 显示文字内容 + const textSpan = document.createElement('span'); + textSpan.className = 'msg-text'; + textSpan.textContent = content; + msgDiv.appendChild(textSpan); + + messagesEl.appendChild(msgDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; +} + +/** + * 挂断电话(用户主动挂断) + */ +export async function hangupCall() { + // 如果已经在挂断中,忽略 + if (callState.isHangingUp) return; + callState.isHangingUp = true; + + // 停止录音 + if (callState.recorder) { + callState.recorder.cancel(); + } + + // 停止当前播放 + if (callState.currentAudio) { + callState.currentAudio.pause(); + callState.currentAudio = null; + } + + // 如果通话已接通,让 AI 说再见 + if (callState.isConnected && !callState.isProcessing) { + try { + updateStatus('对方正在说话...'); + + // 调用 AI 生成告别语 + const { callRealVoiceAI } = await import('./ai.js'); + const goodbyePrompt = '[用户正在挂断电话,请简短地说再见,一句话即可]'; + + const aiResponse = await Promise.race([ + callRealVoiceAI( + callState.contact, + goodbyePrompt, + callState.messages, + callState.initiator + ), + new Promise((_, reject) => setTimeout(() => reject(new Error('超时')), 5000)) + ]); + + const reply = cleanAIReply(aiResponse); + if (reply) { + addCallMessage('ai', reply); + // TTS 播放告别语 + await speakText(reply); + } + } catch (err) { + console.log('[可乐] AI告别语生成失败:', err.message); + } + } + + // 计算通话时长 + let durationStr = '00:00'; + if (callState.isConnected && callState.startTime) { + const elapsed = Math.floor((Date.now() - callState.startTime) / 1000); + const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0'); + const seconds = (elapsed % 60).toString().padStart(2, '0'); + durationStr = `${minutes}:${seconds}`; + } + + // 添加通话记录到聊天历史 + if (callState.contact) { + const contact = callState.contact; + + if (!contact.chatHistory) { + contact.chatHistory = []; + } + + const now = new Date(); + const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`; + + let callContent; + let lastMessage; + + if (callState.isConnected) { + callContent = `[实时语音:${durationStr}]`; + lastMessage = `实时语音 ${durationStr}`; + } else { + if (callState.initiator === 'user') { + if (callState.rejectedByAI) { + callContent = '[实时语音:对方已拒绝]'; + lastMessage = '对方已拒绝'; + } else { + callContent = '[实时语音:已取消]'; + lastMessage = '已取消'; + } + } else if (callState.rejectedByUser) { + callContent = '[实时语音:已拒绝]'; + lastMessage = '已拒绝'; + } else { + callContent = '[实时语音:对方已取消]'; + lastMessage = '对方已取消'; + } + } + + // 通话记录消息 + 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) { + contact.realVoiceCallHistory = Array.isArray(contact.realVoiceCallHistory) ? contact.realVoiceCallHistory : []; + contact.realVoiceCallHistory.push({ + type: 'real-voice', + initiator: callState.initiator, + duration: durationStr, + time: timeStr, + timestamp: Date.now(), + messages: callState.messages.map(m => ({ role: m.role, content: m.content })) + }); + } + + contact.lastMessage = lastMessage; + + // 在聊天界面显示通话记录 + if (currentChatIndex === callState.contactIndex) { + appendCallRecordMessage(callState.initiator === 'user' ? 'user' : 'assistant', durationStr, contact); + } + + requestSave(); + refreshChatList(); + } + + // 隐藏通话页面 + const page = document.getElementById('wechat-real-voice-call-page'); + if (page) { + page.classList.add('hidden'); + } + + clearInterval(callState.timerInterval); + clearInterval(callState.dotsInterval); + + // 如果有缓存的语音,显示保存弹窗 + if (callState.voiceCache && callState.voiceCache.length > 0 && callState.isConnected) { + const callTimestamp = callState.startTime || Date.now(); + const contactIdx = callState.contactIndex; + const cachedVoices = [...callState.voiceCache]; // 复制一份 + + // 重置状态 + resetCallState(); + + // 显示语音保存弹窗 + showVoiceSaveModal(cachedVoices, contactIdx, callTimestamp); + } else { + // 重置状态 + resetCallState(); + } +} + +/** + * 重置通话状态 + */ +function resetCallState() { + callState.isActive = false; + callState.isConnected = false; + callState.isHangingUp = false; + callState.startTime = null; + callState.isRecording = false; + callState.isProcessing = false; + callState.isPlaying = false; + callState.voiceCache = []; +} + +/** + * 在聊天界面显示通话记录 + */ +function appendCallRecordMessage(role, duration, contact) { + const messagesContainer = document.getElementById('wechat-chat-messages'); + if (!messagesContainer) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`; + + const firstChar = contact?.name ? contact.name.charAt(0) : '?'; + + let userAvatarContent = '我'; + try { + const settings = getSettings(); + if (settings.userAvatar) { + userAvatarContent = ``; + } + } catch (e) {} + + const avatarContent = role === 'user' + ? userAvatarContent + : (contact?.avatar + ? `` + : firstChar); + + // 麦克风图标 + const micIconSVG = ` + + + + + `; + + messageDiv.innerHTML = ` +
${avatarContent}
+
+
+
+ ${micIconSVG} + 实时语音 ${duration} +
+
+
+ `; + + messagesContainer.appendChild(messageDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +/** + * 接听来电 + */ +function acceptIncomingCall() { + if (!callState.isActive || callState.isConnected) return; + onCallConnected(); +} + +/** + * 拒绝来电 + */ +function rejectIncomingCall() { + if (!callState.isActive || callState.isConnected) return; + callState.rejectedByUser = true; + hangupCall(); +} + +/** + * 切换静音 + */ +function toggleMute() { + callState.isMuted = !callState.isMuted; + const muteBtn = document.getElementById('wechat-real-voice-call-mute'); + if (muteBtn) { + const btn = muteBtn.querySelector('.wechat-real-voice-call-action-btn'); + const label = muteBtn.querySelector('.wechat-real-voice-call-action-label'); + if (btn) btn.classList.toggle('muted', callState.isMuted); + if (label) label.textContent = callState.isMuted ? '已静音' : '静音'; + } +} + +/** + * 绑定事件 + */ +let eventsBound = false; +function bindCallEvents() { + if (eventsBound) return; + eventsBound = true; + + // 挂断 + document.getElementById('wechat-real-voice-call-hangup')?.addEventListener('click', hangupCall); + + // 静音 + document.getElementById('wechat-real-voice-call-mute')?.addEventListener('click', toggleMute); + + // 最小化 + document.getElementById('wechat-real-voice-call-minimize')?.addEventListener('click', hangupCall); + + // 接听 + document.getElementById('wechat-real-voice-call-accept')?.addEventListener('click', acceptIncomingCall); + + // 拒绝 + document.getElementById('wechat-real-voice-call-reject')?.addEventListener('click', rejectIncomingCall); + + // 说话按钮(点击切换模式:点一次开始录音,再点一次停止录音) + const talkBtn = document.getElementById('wechat-real-voice-call-talk-btn'); + if (talkBtn) { + const toggleRecording = (e) => { + e.preventDefault(); + if (callState.isRecording) { + stopRecording(); + } else { + startRecording(); + } + }; + + // PC 端点击事件 + talkBtn.addEventListener('click', toggleRecording); + + // 移动端触摸事件 + talkBtn.addEventListener('touchend', (e) => { + e.preventDefault(); + toggleRecording(e); + }); + } + + // 文字输入发送按钮(不支持录音时使用) + const textSendBtn = document.getElementById('wechat-real-voice-call-text-send'); + const textInput = document.getElementById('wechat-real-voice-call-text-input'); + + if (textSendBtn && textInput) { + const sendTextMessage = async () => { + const text = textInput.value.trim(); + if (!text || callState.isProcessing || callState.isPlaying) return; + + textInput.value = ''; + await processUserTextInput(text); + }; + + textSendBtn.addEventListener('click', sendTextMessage); + + // 回车发送 + textInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + sendTextMessage(); + } + }); + } +} + +/** + * 显示语音保存弹窗 + */ +function showVoiceSaveModal(voiceList, contactIndex, callTimestamp) { + const modal = document.getElementById('wechat-voice-save-modal'); + const listEl = document.getElementById('wechat-voice-save-list'); + + if (!modal || !listEl) { + console.log('[可乐] 语音保存弹窗元素不存在'); + return; + } + + // 清空并填充列表 + listEl.innerHTML = ''; + + voiceList.forEach((voice, index) => { + const item = document.createElement('div'); + item.className = 'wechat-voice-save-item'; + item.dataset.index = index; + + const durationSec = Math.round(voice.duration || 0); + const durationStr = durationSec > 0 ? `${durationSec}"` : '?秒'; + + item.innerHTML = ` +
+ + +
+
+
${escapeHtml(voice.text.substring(0, 50))}${voice.text.length > 50 ? '...' : ''}
+
${durationStr}
+
+ + `; + + listEl.appendChild(item); + }); + + // 绑定试听按钮 + listEl.querySelectorAll('.wechat-voice-save-play').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + const voice = voiceList[idx]; + if (voice && voice.audioBlob) { + try { + btn.disabled = true; + btn.innerHTML = ''; + + const audioUrl = URL.createObjectURL(voice.audioBlob); + const audio = new Audio(audioUrl); + audio.onended = () => { + URL.revokeObjectURL(audioUrl); + btn.disabled = false; + btn.innerHTML = ''; + }; + audio.onerror = () => { + URL.revokeObjectURL(audioUrl); + btn.disabled = false; + btn.innerHTML = ''; + }; + await audio.play(); + } catch (err) { + console.error('[可乐] 试听失败:', err); + btn.disabled = false; + btn.innerHTML = ''; + } + } + }); + }); + + // 绑定保存按钮 + const confirmBtn = document.getElementById('wechat-voice-save-confirm'); + const skipBtn = document.getElementById('wechat-voice-save-skip'); + const cancelBtn = document.getElementById('wechat-voice-save-cancel'); + + const closeModal = () => { + modal.classList.add('hidden'); + }; + + const handleSave = async () => { + // 获取选中的语音 + const selectedVoices = []; + listEl.querySelectorAll('.wechat-voice-save-item').forEach((item, idx) => { + const checkbox = item.querySelector('input[type="checkbox"]'); + if (checkbox && checkbox.checked) { + selectedVoices.push({ + contactIndex: contactIndex, + callTimestamp: callTimestamp, + text: voiceList[idx].text, + audioBlob: voiceList[idx].audioBlob, + duration: voiceList[idx].duration + }); + } + }); + + if (selectedVoices.length > 0) { + try { + await saveVoiceRecordings(selectedVoices); + showToast(`已保存 ${selectedVoices.length} 条语音`, '✓'); + } catch (err) { + console.error('[可乐] 保存语音失败:', err); + showToast('保存失败', '⚠️'); + } + } + + closeModal(); + }; + + // 移除旧的事件监听器 + const newConfirmBtn = confirmBtn.cloneNode(true); + const newSkipBtn = skipBtn.cloneNode(true); + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); + skipBtn.parentNode.replaceChild(newSkipBtn, skipBtn); + + newConfirmBtn.addEventListener('click', handleSave); + newSkipBtn.addEventListener('click', closeModal); + + if (cancelBtn) { + const newCancelBtn = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(newCancelBtn, cancelBtn); + newCancelBtn.addEventListener('click', closeModal); + } + + // 显示弹窗 + modal.classList.remove('hidden'); +} + +/** + * HTML 转义 + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * 初始化 + */ +export function initRealVoiceCall() { + // 事件绑定将在显示页面时进行 +} + +/** + * 获取通话状态 + */ +export function getRealVoiceCallState() { + return { + isActive: callState.isActive, + isConnected: callState.isConnected, + contactIndex: callState.contactIndex + }; +} diff --git a/style.css b/style.css index 3e8e272..76f8c1e 100644 --- a/style.css +++ b/style.css @@ -4888,7 +4888,11 @@ background: rgba(50, 50, 50, 0.8); border-radius: 20px; padding: 4px 4px 4px 16px; - margin-top: auto; + margin: 0 16px 15px 16px; +} + +.wechat-voice-call-input-area.hidden { + display: none; } .wechat-voice-call-input { @@ -5300,6 +5304,11 @@ display: flex; gap: 8px; padding: 8px 0; + margin: 0 16px 10px 16px; +} + +.wechat-video-call-input-area.hidden { + display: none; } .wechat-video-call-input { @@ -5467,6 +5476,573 @@ transform: scale(1.05); } +/* ===== 实时语音通话页面 ===== */ +.wechat-real-voice-call-page { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + z-index: 1100; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wechat-real-voice-call-page.hidden { + display: none; +} + +.wechat-real-voice-call-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: transparent; + position: relative; + z-index: 10; +} + +.wechat-real-voice-call-minimize { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255,255,255,0.15); + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.wechat-real-voice-call-minimize:hover { + background: rgba(255,255,255,0.25); +} + +.wechat-real-voice-call-time { + font-size: 16px; + color: #fff; + font-weight: 500; +} + +.wechat-real-voice-call-time.hidden { + visibility: hidden; +} + +.wechat-real-voice-call-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + overflow: hidden; +} + +.wechat-real-voice-call-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #fff; + margin-bottom: 16px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + overflow: hidden; +} + +.wechat-real-voice-call-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.wechat-real-voice-call-name { + font-size: 22px; + color: #fff; + font-weight: 600; + margin-bottom: 8px; + text-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.wechat-real-voice-call-status { + font-size: 14px; + color: rgba(255,255,255,0.7); + margin-bottom: 20px; +} + +.wechat-real-voice-call-status.connecting { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* 实时语音对话区域 */ +.wechat-real-voice-call-chat { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + max-width: 320px; + margin: 0 auto; + background: rgba(255,255,255,0.05); + border-radius: 16px; + overflow: hidden; + backdrop-filter: blur(10px); +} + +.wechat-real-voice-call-chat.hidden { + display: none; +} + +.wechat-real-voice-call-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 120px; + max-height: 200px; +} + +.wechat-real-voice-call-messages::-webkit-scrollbar { + width: 4px; +} + +.wechat-real-voice-call-messages::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.2); + border-radius: 2px; +} + +.wechat-real-voice-call-msg { + max-width: 85%; + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + word-break: break-word; +} + +.wechat-real-voice-call-msg.ai { + align-self: flex-start; + background: rgba(255,255,255,0.15); + color: #fff; + border-bottom-left-radius: 4px; +} + +.wechat-real-voice-call-msg.user { + align-self: flex-end; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; + border-bottom-right-radius: 4px; +} + +.wechat-real-voice-call-msg.fade-in { + animation: msgFadeIn 0.3s ease-out; +} + +@keyframes msgFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 按住说话区域 */ +.wechat-real-voice-call-talk-area { + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.wechat-real-voice-call-talk-area.hidden { + display: none; +} + +.wechat-real-voice-call-talk-btn { + width: 100%; + max-width: 280px; + padding: 16px 24px; + border-radius: 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: #fff; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + user-select: none; + -webkit-user-select: none; + touch-action: none; +} + +.wechat-real-voice-call-talk-btn:hover { + transform: scale(1.02); + box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4); +} + +.wechat-real-voice-call-talk-btn:active, +.wechat-real-voice-call-talk-btn.recording { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%); + transform: scale(0.98); +} + +.wechat-real-voice-call-talk-hint { + font-size: 12px; + color: rgba(255,255,255,0.5); +} + +/* 文字输入区域(不支持录音时) */ +.wechat-real-voice-call-text-input-area { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + padding: 0 20px; + width: 100%; + box-sizing: border-box; +} + +.wechat-real-voice-call-text-input { + flex: 1; + height: 40px; + border: none; + border-radius: 20px; + padding: 0 16px; + font-size: 14px; + background: rgba(255,255,255,0.15); + color: #fff; + outline: none; +} + +.wechat-real-voice-call-text-input::placeholder { + color: rgba(255,255,255,0.5); +} + +.wechat-real-voice-call-text-send { + height: 40px; + padding: 0 20px; + border: none; + border-radius: 20px; + background: #07c160; + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.wechat-real-voice-call-text-send:active { + background: #06ad56; +} + +/* 操作按钮区域 */ +.wechat-real-voice-call-actions { + display: flex; + justify-content: center; + gap: 48px; + padding: 24px; + background: transparent; +} + +.wechat-real-voice-call-action { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.wechat-real-voice-call-action-btn { + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(255,255,255,0.15); + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.wechat-real-voice-call-action-btn:hover { + background: rgba(255,255,255,0.25); + transform: scale(1.05); +} + +.wechat-real-voice-call-action-btn.hangup { + background: #ff4444; +} + +.wechat-real-voice-call-action-btn.hangup:hover { + background: #ff6666; +} + +.wechat-real-voice-call-action-btn.muted { + background: rgba(255,68,68,0.3); +} + +.wechat-real-voice-call-action-btn.muted svg { + opacity: 0.7; +} + +.wechat-real-voice-call-action-label { + font-size: 12px; + color: rgba(255,255,255,0.7); +} + +/* 来电操作按钮 */ +.wechat-real-voice-call-incoming-actions { + display: flex; + justify-content: center; + gap: 60px; + padding: 24px; +} + +.wechat-real-voice-call-incoming-actions.hidden { + display: none; +} + +.wechat-real-voice-call-action-btn.reject { + background: #ff4444; +} + +.wechat-real-voice-call-action-btn.reject:hover { + background: #ff6666; +} + +.wechat-real-voice-call-action-btn.accept { + background: #07c160; +} + +.wechat-real-voice-call-action-btn.accept:hover { + background: #1ed76a; +} + +/* 实时语音通话记录样式 */ +.wechat-real-voice-record .wechat-call-record-icon { + transform: none; +} + +/* ===== 语音 API 设置面板 ===== */ +.wechat-voice-api-panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: var(--wechat-bg); + border-radius: 16px 16px 0 0; + box-shadow: 0 -4px 20px rgba(0,0,0,0.15); + z-index: 200; + max-height: 80%; + overflow-y: auto; + animation: slideUp 0.3s ease-out; +} + +.wechat-voice-api-panel.hidden { + display: none; +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.wechat-voice-api-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--wechat-border); + position: sticky; + top: 0; + background: var(--wechat-bg); + z-index: 10; +} + +.wechat-voice-api-title { + font-size: 16px; + font-weight: 600; + color: var(--wechat-text-primary); +} + +.wechat-voice-api-close { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--wechat-border); + border: none; + color: var(--wechat-text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; +} + +.wechat-voice-api-close:hover { + background: var(--wechat-hover); +} + +.wechat-voice-api-content { + padding: 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.wechat-voice-api-section { + background: var(--wechat-card-bg, #fff); + border-radius: 12px; + padding: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); +} + +.wechat-dark .wechat-voice-api-section { + background: var(--wechat-card-bg, #1e1e1e); +} + +.wechat-voice-api-section-title { + font-size: 14px; + font-weight: 600; + color: var(--wechat-text-primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.wechat-voice-api-section-title svg { + width: 18px; + height: 18px; + opacity: 0.7; +} + +.wechat-voice-api-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.wechat-voice-api-row:last-child { + margin-bottom: 0; +} + +.wechat-voice-api-label { + font-size: 12px; + color: var(--wechat-text-secondary); +} + +.wechat-voice-api-input-group { + display: flex; + gap: 8px; +} + +.wechat-voice-api-input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--wechat-border); + border-radius: 8px; + font-size: 14px; + background: var(--wechat-bg); + color: var(--wechat-text-primary); +} + +.wechat-voice-api-input:focus { + outline: none; + border-color: var(--wechat-primary); +} + +.wechat-voice-api-input::placeholder { + color: var(--wechat-text-secondary); + opacity: 0.6; +} + +.wechat-voice-api-eye-btn { + width: 40px; + height: 40px; + border: 1px solid var(--wechat-border); + border-radius: 8px; + background: var(--wechat-bg); + color: var(--wechat-text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.wechat-voice-api-eye-btn:hover { + background: var(--wechat-hover); +} + +.wechat-voice-api-test-btn { + padding: 10px 16px; + border: 1px solid var(--wechat-primary); + border-radius: 8px; + background: transparent; + color: var(--wechat-primary); + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +.wechat-voice-api-test-btn:hover { + background: var(--wechat-primary); + color: #fff; +} + +.wechat-voice-api-test-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.wechat-voice-api-row-inline { + display: flex; + gap: 12px; +} + +.wechat-voice-api-row-inline .wechat-voice-api-row { + flex: 1; + margin-bottom: 0; +} + +.wechat-voice-api-actions { + display: flex; + gap: 12px; + padding: 16px; + border-top: 1px solid var(--wechat-border); + background: var(--wechat-bg); + position: sticky; + bottom: 0; +} + +.wechat-voice-api-save-btn { + flex: 1; + padding: 12px; + border: none; + border-radius: 8px; + background: var(--wechat-primary); + color: #fff; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.wechat-voice-api-save-btn:hover { + opacity: 0.9; +} + /* ===== 音乐搜索面板 ===== */ .wechat-music-panel { position: absolute; @@ -11951,6 +12527,114 @@ opacity: 1; } +/* ========== 语音回放标签样式 ========== */ +.wechat-history-tab-green { + background: linear-gradient(135deg, #07c160, #06ad56) !important; +} + +.wechat-history-tab-green.active { + opacity: 1; +} + +/* 语音回放列表 */ +.wechat-voice-playback-list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; +} + +.wechat-voice-playback-card { + background: linear-gradient(135deg, #f0fff4, #e6ffed); + border-radius: 12px; + padding: 12px; + box-shadow: 0 2px 8px rgba(7, 193, 96, 0.15); +} + +.wechat-dark .wechat-voice-playback-card { + background: linear-gradient(135deg, #1a3025, #0d2818); +} + +.wechat-voice-playback-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.wechat-voice-playback-time { + font-size: 12px; + color: var(--wechat-text-secondary); +} + +.wechat-voice-playback-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.wechat-voice-playback-duration { + font-size: 12px; + color: #07c160; + font-weight: 500; +} + +.wechat-voice-playback-delete { + width: 20px; + height: 20px; + border: none; + background: rgba(255, 77, 79, 0.1); + color: #ff4d4f; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.wechat-voice-playback-delete:hover { + background: rgba(255, 77, 79, 0.2); +} + +.wechat-voice-playback-content { + display: flex; + align-items: center; + gap: 10px; +} + +.wechat-voice-playback-text { + flex: 1; + font-size: 13px; + color: var(--wechat-text-primary); + line-height: 1.5; + word-break: break-word; +} + +.wechat-voice-playback-btn { + width: 36px; + height: 36px; + border-radius: 50%; + background: #07c160; + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s; +} + +.wechat-voice-playback-btn:hover { + background: #06ad56; +} + +.wechat-voice-playback-btn:disabled { + background: #aaa; + cursor: not-allowed; +} + /* 心动瞬间卡片样式 */ .wechat-toy-history-card { background: linear-gradient(135deg, #fff5f8, #ffe4ec); @@ -12207,3 +12891,151 @@ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; } + +/* 语音回放保存弹窗 */ +.wechat-voice-save-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.wechat-voice-save-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--wechat-bg-light); + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.wechat-voice-save-item:hover { + background: var(--wechat-bg-hover); +} + +.wechat-voice-save-item.selected { + background: rgba(7, 193, 96, 0.15); + border: 1px solid #07c160; +} + +.wechat-voice-save-checkbox { + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.wechat-voice-save-checkbox input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 1; +} + +.wechat-voice-save-checkbox label { + position: absolute; + top: 0; + left: 0; + width: 18px; + height: 18px; + border: 2px solid var(--wechat-border); + border-radius: 4px; + background: transparent; + transition: all 0.2s; +} + +.wechat-voice-save-checkbox input[type="checkbox"]:checked + label { + background: #07c160; + border-color: #07c160; +} + +.wechat-voice-save-checkbox label::after { + content: ''; + position: absolute; + top: 2px; + left: 5px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s; +} + +.wechat-voice-save-checkbox input[type="checkbox"]:checked + label::after { + opacity: 1; +} + +.wechat-voice-save-info { + flex: 1; + min-width: 0; +} + +.wechat-voice-save-text { + flex: 1; + font-size: 13px; + color: var(--wechat-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wechat-voice-save-play { + width: 28px; + height: 28px; + border-radius: 50%; + background: #07c160; + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.wechat-voice-save-play:hover { + background: #06ad56; +} + +.wechat-voice-save-play svg { + width: 14px; + height: 14px; +} + +.wechat-voice-save-duration { + font-size: 12px; + color: var(--wechat-text-secondary); + flex-shrink: 0; +} + +/* 语音回放气泡 */ +.wechat-voice-playback { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 6px 10px; + background: rgba(7, 193, 96, 0.1); + border-radius: 6px; + cursor: pointer; +} + +.wechat-voice-playback:hover { + background: rgba(7, 193, 96, 0.2); +} + +.wechat-voice-playback-icon { + width: 20px; + height: 20px; + color: #07c160; +} + +.wechat-voice-playback-text { + font-size: 12px; + color: #07c160; +} diff --git a/summary.js b/summary.js index 8c2defc..9efccfe 100644 --- a/summary.js +++ b/summary.js @@ -311,23 +311,23 @@ export function generateSummaryPrompt(allChats, cupNumber) { prompt = settings.customSummaryTemplate.trim() + '\n\n'; } else { // 使用默认模板(纯对话记录模式) - prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式。 + prompt = `【重要】你必须且只能输出一个JSON对象,禁止输出任何其他内容。 -【核心原则】 -- 原样保留:完整复制每一条对话,不做任何修改、润色或总结 -- 格式统一:按"发言者: 内容"格式逐行记录 -- 仅提取关键词:从对话中提取3-5个核心关键词用于检索触发 +你的任务是将聊天记录整理成JSON格式。 -【输出格式要求】 -- 只输出一个JSON对象 -- 不要使用markdown代码块 -- 直接以 { 开头,以 } 结尾 -- keys: 3-5个能代表本次聊天核心内容的关键词(人名、地点、事件等) -- content: 以"以下是线上聊天内容:"开头,然后原样复制对话记录,每条一行,格式为"发言者: 内容" -- comment: "${getCupName(cupNumber)}" +【输出规则 - 必须严格遵守】 +1. 直接以 { 开头,以 } 结尾 +2. 禁止使用markdown代码块(禁止\`\`\`) +3. 禁止输出任何解释、思考、前言 +4. 禁止在JSON前后添加任何文字 -【JSON示例】 -{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"} +【JSON字段说明】 +- "keys": 数组,3-5个关键词(人名、地点、事件等) +- "content": 字符串,以"以下是线上聊天内容:"开头,然后逐行记录对话,格式为"发言者: 内容",用\\n分隔 +- "comment": "${getCupName(cupNumber)}" + +【正确输出示例】 +{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧","comment":"${getCupName(cupNumber)}"} `; } @@ -469,13 +469,13 @@ export function generateSummaryPrompt(allChats, cupNumber) { }); }); - prompt += `\n请将以上聊天记录原样整理成${getCupName(cupNumber)}的JSON:`; + prompt += `\n【立即输出JSON】请将以上聊天记录整理成${getCupName(cupNumber)}的JSON对象(直接以{开头):`; return prompt; } // 调用总结API -export async function callSummaryAPI(prompt) { +export async function callSummaryAPI(prompt, cupNumber = 1) { const settings = getSettings(); const apiUrl = settings.summaryApiUrl; const apiKey = settings.summaryApiKey; @@ -513,14 +513,16 @@ export async function callSummaryAPI(prompt) { const content = data.choices?.[0]?.message?.content || ''; // 解析JSON - const parsed = parseJSONResponse(content); + const parsed = parseJSONResponse(content, cupNumber); if (parsed) return parsed; throw new Error('AI返回内容为空或无法解析'); } // 解析JSON响应 -function parseJSONResponse(content) { +function parseJSONResponse(content, cupNumber = 1) { + if (!content || !content.trim()) return null; + // 方法1: 直接解析 try { const result = JSON.parse(content); @@ -545,16 +547,26 @@ function parseJSONResponse(content) { } } catch (e) {} - // 降级方案 - if (content && content.trim().length > 20) { - const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录']; - return { - keys: [...new Set(words)].slice(0, 5), - content: content.substring(0, 30000).replace(/```[\s\S]*?```/g, '').trim(), - comment: '感情记录' - }; - } + // 方法4: 尝试修复常见的JSON格式问题 + try { + // 替换中文冒号和引号 + let fixed = content + .replace(/:/g, ':') + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'"); + const firstBrace = fixed.indexOf('{'); + const lastBrace = fixed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + const result = JSON.parse(fixed.substring(firstBrace, lastBrace + 1)); + if (result.keys && result.content) return result; + } + } catch (e) {} + + // 不再使用降级方案,返回null让调用者处理错误 + console.error('[可乐] JSON解析失败,原始内容前500字符:', content.substring(0, 500)); return null; } @@ -732,7 +744,7 @@ export async function executeSummary() { // 为单个聊天生成总结 updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...'); const prompt = generateSummaryPrompt([chat], cupNumber); - const entry = await callSummaryAPI(prompt); + const entry = await callSummaryAPI(prompt, cupNumber); // 保存到收藏 saveEntryToFavorites(entry, cupNumber, lorebookName); diff --git a/video-call.js b/video-call.js index 9d3824a..a8370a9 100644 --- a/video-call.js +++ b/video-call.js @@ -101,6 +101,7 @@ function showIncomingCallPage() { // 隐藏主界面元素,显示来电界面 document.getElementById('wechat-video-call-center')?.classList.add('hidden'); document.getElementById('wechat-video-call-chat')?.classList.add('hidden'); + document.getElementById('wechat-video-call-input-area')?.classList.add('hidden'); document.getElementById('wechat-video-call-actions')?.classList.add('hidden'); incomingEl.classList.remove('hidden'); @@ -165,8 +166,9 @@ function showCallPage() { timeEl.classList.add('hidden'); // 拨打中不显示计时 } - // 隐藏对话框 + // 隐藏对话框和输入框 document.getElementById('wechat-video-call-chat')?.classList.add('hidden'); + document.getElementById('wechat-video-call-input-area')?.classList.add('hidden'); document.getElementById('wechat-video-call-messages')?.innerHTML && (document.getElementById('wechat-video-call-messages').innerHTML = ''); @@ -266,8 +268,9 @@ function onVideoCallConnected() { document.getElementById('wechat-video-call-incoming')?.classList.add('hidden'); document.getElementById('wechat-video-call-actions')?.classList.remove('hidden'); - // 显示对话框 + // 显示对话框和输入框 document.getElementById('wechat-video-call-chat')?.classList.remove('hidden'); + document.getElementById('wechat-video-call-input-area')?.classList.remove('hidden'); // 接通后才显示计时 const timeEl = document.getElementById('wechat-video-call-time'); diff --git a/voice-api.js b/voice-api.js new file mode 100644 index 0000000..845db80 --- /dev/null +++ b/voice-api.js @@ -0,0 +1,731 @@ +/** + * 语音 API 封装 + * TTS (文字转语音) 和 STT (语音转文字) + */ + +import { getSettings } from './config.js'; + +/** + * 获取语音 API 配置 + * @param {Object} contact - 角色对象(可选,用于获取角色独立配置) + * @returns {Object} 配置对象 + */ +export function getVoiceApiConfig(contact = null) { + const settings = getSettings(); + + // 基础配置 + const config = { + stt: { + url: settings.sttApiUrl || '', + key: settings.sttApiKey || '', + model: settings.sttModel || '' + }, + tts: { + url: settings.ttsApiUrl || '', + key: settings.ttsApiKey || '', + model: settings.ttsModel || '', + voice: settings.ttsVoice || '', + speed: settings.ttsSpeed || 1, + emotion: settings.ttsEmotion || '默认', + proxyUrl: settings.ttsProxyUrl || '' + } + }; + + // 角色独立 TTS 配置 + if (contact?.useCustomVoice && contact.customTtsVoice) { + config.tts.voice = contact.customTtsVoice; + } + + return config; +} + +/** + * 根据 Blob 类型获取文件名 + */ +function getAudioFileName(blob) { + const type = blob.type || 'audio/webm'; + if (type.includes('webm')) return 'audio.webm'; + if (type.includes('ogg')) return 'audio.ogg'; + if (type.includes('mp4')) return 'audio.mp4'; + if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3'; + if (type.includes('wav')) return 'audio.wav'; + if (type.includes('flac')) return 'audio.flac'; + return 'audio.webm'; +} + +/** + * 将音频 Blob 转换为 WAV 格式(更好的兼容性) + * 导出供其他模块使用 + */ +export async function convertToWav(audioBlob) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const arrayBuffer = await audioBlob.arrayBuffer(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // 创建 WAV 文件 + const numChannels = audioBuffer.numberOfChannels; + const sampleRate = audioBuffer.sampleRate; + const format = 1; // PCM + const bitDepth = 16; + + const bytesPerSample = bitDepth / 8; + const blockAlign = numChannels * bytesPerSample; + + const samples = audioBuffer.length; + const dataSize = samples * blockAlign; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + // WAV 头部 + const writeString = (offset, str) => { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); + view.setUint32(4, 36 + dataSize, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, format, true); + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, dataSize, true); + + // 写入音频数据 + const channelData = []; + for (let i = 0; i < numChannels; i++) { + channelData.push(audioBuffer.getChannelData(i)); + } + + let offset = 44; + for (let i = 0; i < samples; i++) { + for (let ch = 0; ch < numChannels; ch++) { + const sample = Math.max(-1, Math.min(1, channelData[ch][i])); + const intSample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; + view.setInt16(offset, intSample, true); + offset += 2; + } + } + + await audioContext.close(); + return new Blob([buffer], { type: 'audio/wav' }); + } catch (err) { + console.warn('[可乐] WAV 转换失败,使用原格式:', err); + return audioBlob; + } +} + +/** + * STT: 语音转文字 + * @param {Blob} audioBlob - 音频数据 + * @param {Object} options - 选项 + * @returns {Promise} 识别的文字 + */ +export async function speechToText(audioBlob, options = {}) { + const config = getVoiceApiConfig(); + + if (!config.stt.url || !config.stt.key) { + throw new Error('请先配置语音识别 (STT) API'); + } + + // 自动补全 URL 路径 + let sttUrl = config.stt.url.trim().replace(/\/+$/, ''); + if (!sttUrl.includes('/audio/transcriptions')) { + sttUrl = sttUrl + '/audio/transcriptions'; + } + + // 如果不是 WAV 格式,尝试转换以提高兼容性 + let processedBlob = audioBlob; + if (!audioBlob.type.includes('wav')) { + console.log('[可乐] 转换音频为 WAV 格式...'); + processedBlob = await convertToWav(audioBlob); + } + + // 根据音频类型设置正确的文件名 + const fileName = getAudioFileName(processedBlob); + + const formData = new FormData(); + formData.append('file', processedBlob, fileName); + + if (config.stt.model) { + formData.append('model', config.stt.model); + } + + try { + console.log('[可乐] STT 请求:', { + url: sttUrl, + model: config.stt.model, + originalType: audioBlob.type, + processedType: processedBlob.type, + audioSize: processedBlob.size, + fileName: fileName + }); + + const response = await fetch(sttUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.stt.key}` + }, + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[可乐] STT API 错误:', response.status, errorText); + // 尝试解析 JSON 错误 + try { + const errorJson = JSON.parse(errorText); + const errorMsg = errorJson.error?.message || errorJson.message || errorText; + throw new Error(errorMsg); + } catch (parseErr) { + // 如果不是 JSON 解析错误,而是 throw 的错误,重新抛出 + if (parseErr.message && !parseErr.message.includes('JSON')) { + throw parseErr; + } + throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 200)}`); + } + } + + const result = await response.json(); + console.log('[可乐] STT 响应:', result); + return result.text || ''; + } catch (err) { + console.error('[可乐] STT 请求失败:', err); + throw err; + } +} + +/** + * TTS: 文字转语音 + * @param {string} text - 要合成的文字 + * @param {Object} contact - 角色对象(用于获取角色独立音色) + * @param {Object} options - 选项 + * @returns {Promise} 音频 Blob + */ +export async function textToSpeech(text, contact = null, options = {}) { + const config = getVoiceApiConfig(contact); + + if (!config.tts.url || !config.tts.key) { + throw new Error('请先配置语音合成 (TTS) API'); + } + + if (!text || !text.trim()) { + throw new Error('合成文字不能为空'); + } + + // 自动补全 URL 路径 + let ttsUrl = config.tts.url.trim().replace(/\/+$/, ''); + if (!ttsUrl.includes('/audio/speech')) { + ttsUrl = ttsUrl + '/audio/speech'; + } + + // 构建请求体 + const model = (options.model || config.tts.model || '').trim(); + const voice = (options.voice || config.tts.voice || '').trim(); + + // 检查必填字段 + if (!model) { + throw new Error('请先配置 TTS 模型'); + } + if (!voice) { + throw new Error('请先配置 TTS 音色'); + } + + // 检测是否是 Gemini TTS 模型 + const isGeminiTTS = model.toLowerCase().includes('gemini') && model.toLowerCase().includes('tts'); + // 检测是否是 GSVI 模型 (gsv2p.acgnai.top) + const isGSVI = model.toLowerCase().includes('gsvi'); + // 检测是否是 MiniMax TTS API + const isMiniMax = ttsUrl.toLowerCase().includes('minimax') || ttsUrl.includes('/t2a_v2'); + + // MiniMax API 使用完全不同的格式 + if (isMiniMax) { + // 修正 URL:MiniMax 使用 /v1/t2a_v2 而不是 /audio/speech + ttsUrl = ttsUrl.replace(/\/audio\/speech$/, '/t2a_v2'); + if (!ttsUrl.includes('/t2a_v2')) { + ttsUrl = ttsUrl.replace(/\/+$/, '') + '/t2a_v2'; + } + + // 如果配置了代理 URL,使用代理(解决 CORS 问题) + if (config.tts.proxyUrl) { + const proxyBase = config.tts.proxyUrl.trim().replace(/\/+$/, ''); + // 提取 MiniMax URL 的路径部分 + const urlObj = new URL(ttsUrl); + ttsUrl = proxyBase + urlObj.pathname; + console.log('[可乐] MiniMax 使用代理:', ttsUrl); + } + } + + // 构建请求体 + let requestBody; + + if (isMiniMax) { + // MiniMax API 格式 + const speed = options.speed || config.tts.speed || 1; + const emotion = options.emotion || config.tts.emotion; + + requestBody = { + model: model, + text: text.trim(), + stream: false, + voice_setting: { + voice_id: voice, + speed: speed, + vol: 1, + pitch: 0 + }, + audio_setting: { + sample_rate: 32000, + bitrate: 128000, + format: 'mp3', + channel: 1 + } + }; + + // 添加情绪参数(只有有效值才添加) + if (emotion && emotion !== '默认') { + const emotionMap = { + '高兴': 'happy', + '悲伤': 'sad', + '愤怒': 'angry', + '害怕': 'fearful', + '厌恶': 'disgusted', + '惊讶': 'surprised', + '中性': 'calm', + '生动': 'fluent', + '低语': 'whisper' + }; + // 只有在 emotionMap 中有对应值时才添加 + const mappedEmotion = emotionMap[emotion]; + if (mappedEmotion) { + requestBody.voice_setting.emotion = mappedEmotion; + } + } + } else { + requestBody = { + model: model, + voice: voice + }; + + // GSVI 模型只需要基本参数 + if (isGSVI) { + requestBody.input = text.trim(); + // GSVI API 不需要 language 和 emotion 参数 + } else { + // OpenAI 标准格式使用 input + requestBody.input = text.trim(); + + // 非 Gemini TTS 时才添加额外参数 + if (!isGeminiTTS) { + // 只有非默认语速才添加 speed 参数 + const speed = options.speed || config.tts.speed || 1; + if (speed !== 1) { + requestBody.speed = speed; + } + + // 扩展参数 (GPT-SoVITS 等支持) + const emotion = options.emotion || config.tts.emotion; + if (emotion && emotion !== '默认') { + requestBody.other_params = { + text_lang: '中英混合', + prompt_lang: '中文', + emotion: emotion + }; + } + } + } + } + + try { + const textContent = requestBody.input || requestBody.text || ''; + console.log('[可乐] TTS 请求:', { + url: ttsUrl, + model: model, + voice: voice, + isGSVI: isGSVI, + isGeminiTTS: isGeminiTTS, + isMiniMax: isMiniMax, + textLength: textContent.length, + textFull: textContent // 打印完整文本 + }); + + const response = await fetch(ttsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': isMiniMax ? 'application/json' : 'audio/mpeg, audio/wav, audio/*', + 'Authorization': `Bearer ${config.tts.key}` + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[可乐] TTS API 错误:'); + console.error(' 状态码:', response.status); + console.error(' 响应内容:', errorText); + console.error(' 请求URL:', ttsUrl); + console.error(' 请求体:', JSON.stringify(requestBody, null, 2)); + + // 尝试解析 JSON 错误 + try { + const errorJson = JSON.parse(errorText); + // MiniMax 错误格式: base_resp.status_msg + const errorMsg = errorJson.base_resp?.status_msg || errorJson.error?.message || errorJson.message || errorJson.error || errorText; + throw new Error(typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)); + } catch (parseErr) { + if (parseErr.message && !parseErr.message.includes('JSON')) { + throw parseErr; + } + throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 300)}`); + } + } + + // MiniMax API 返回 JSON,需要特殊处理 + if (isMiniMax) { + const jsonResp = await response.json(); + console.log('[可乐] MiniMax TTS 响应:', { + status_code: jsonResp.base_resp?.status_code, + status_msg: jsonResp.base_resp?.status_msg, + audio_length: jsonResp.extra_info?.audio_length, + audio_format: jsonResp.extra_info?.audio_format + }); + + // 检查 MiniMax 错误 + if (jsonResp.base_resp?.status_code !== 0) { + throw new Error('MiniMax TTS 错误: ' + (jsonResp.base_resp?.status_msg || '未知错误')); + } + + if (!jsonResp.data?.audio) { + throw new Error('MiniMax TTS 未返回音频数据'); + } + + // 将 hex 编码的音频转换为 Blob + const hexAudio = jsonResp.data.audio; + const bytes = new Uint8Array(hexAudio.length / 2); + for (let i = 0; i < hexAudio.length; i += 2) { + bytes[i / 2] = parseInt(hexAudio.substr(i, 2), 16); + } + + const audioFormat = jsonResp.extra_info?.audio_format || 'mp3'; + const mimeType = `audio/${audioFormat}`; + return new Blob([bytes], { type: mimeType }); + } + + const audioBlob = await response.blob(); + console.log('[可乐] TTS 响应:', { + 音频大小: audioBlob.size, + 类型: audioBlob.type, + 响应头ContentType: response.headers.get('content-type') + }); + + // 先检查是否返回了错误的 JSON(有些 API 错误时返回 JSON) + const contentType = response.headers.get('content-type') || audioBlob.type; + if (contentType.includes('application/json') || contentType.includes('text/')) { + const text = await audioBlob.text(); + console.error('[可乐] TTS 返回了文本而非音频:', text); + try { + const errJson = JSON.parse(text); + const errMsg = errJson.error?.message || errJson.message || errJson.error || JSON.stringify(errJson); + throw new Error('TTS 错误: ' + errMsg); + } catch (e) { + if (e.message.includes('TTS')) throw e; + throw new Error('TTS 返回了非音频数据: ' + text.substring(0, 100)); + } + } + + // 检查是否返回了有效的音频数据 + if (audioBlob.size < 100) { + console.error('[可乐] TTS 返回的数据太小,可能不是有效音频'); + throw new Error('TTS 返回的音频数据无效'); + } + + // 修复:如果 blob 类型为空或不是音频类型,手动指定 MIME 类型 + // 某些 TTS API(如 GPT-SoVITS)返回的音频没有正确的 Content-Type + let finalBlob = audioBlob; + if (!audioBlob.type || audioBlob.type === '' || !audioBlob.type.startsWith('audio/')) { + // 尝试从 Content-Type 头获取类型,或使用默认的 audio/wav + let mimeType = 'audio/wav'; + const headerType = response.headers.get('content-type'); + if (headerType && headerType.startsWith('audio/')) { + mimeType = headerType.split(';')[0].trim(); + } else if (headerType && headerType.includes('octet-stream')) { + // application/octet-stream 通常是 wav 格式 + mimeType = 'audio/wav'; + } + + console.log('[可乐] TTS blob 类型为空,手动指定为:', mimeType); + const arrayBuffer = await audioBlob.arrayBuffer(); + finalBlob = new Blob([arrayBuffer], { type: mimeType }); + } + + return finalBlob; + } catch (err) { + console.error('[可乐] TTS 请求失败:', err); + // 检查是否是网络错误 + if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) { + throw new Error('网络连接失败,请检查 API 地址是否正确,或尝试使用代理'); + } + throw err; + } +} + +/** + * 播放音频 + * @param {Blob|string} audio - 音频 Blob 或 URL + * @returns {Promise} Audio 元素 + */ +export function playAudio(audio) { + return new Promise((resolve, reject) => { + const audioEl = new Audio(); + + if (audio instanceof Blob) { + audioEl.src = URL.createObjectURL(audio); + } else { + audioEl.src = audio; + } + + audioEl.onended = () => { + if (audio instanceof Blob) { + URL.revokeObjectURL(audioEl.src); + } + resolve(audioEl); + }; + + audioEl.onerror = (err) => { + if (audio instanceof Blob) { + URL.revokeObjectURL(audioEl.src); + } + reject(err); + }; + + audioEl.play().catch(reject); + }); +} + +/** + * 录音类 + */ +export class AudioRecorder { + constructor() { + this.mediaRecorder = null; + this.audioChunks = []; + this.stream = null; + this.isRecording = false; + this.mimeType = 'audio/webm'; + } + + /** + * 开始录音 + * @returns {Promise} + */ + async start() { + if (this.isRecording) return; + + try { + this.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // 选择最佳支持的音频格式 + this.mimeType = getSupportedMimeType(); + console.log('[可乐] 录音使用格式:', this.mimeType); + + this.mediaRecorder = new MediaRecorder(this.stream, { + mimeType: this.mimeType + }); + this.audioChunks = []; + + this.mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + this.audioChunks.push(e.data); + } + }; + + this.mediaRecorder.start(100); // 每100ms收集一次数据 + this.isRecording = true; + console.log('[可乐] 开始录音'); + } catch (err) { + console.error('[可乐] 无法获取麦克风权限:', err); + throw new Error('无法获取麦克风权限,请检查浏览器设置'); + } + } + + /** + * 停止录音 + * @returns {Promise} 录音数据 + */ + stop() { + return new Promise((resolve, reject) => { + if (!this.isRecording || !this.mediaRecorder) { + reject(new Error('没有正在进行的录音')); + return; + } + + const mimeType = this.mimeType; + + this.mediaRecorder.onstop = () => { + const audioBlob = new Blob(this.audioChunks, { type: mimeType }); + this.cleanup(); + console.log('[可乐] 录音结束,格式:', mimeType, '大小:', audioBlob.size); + resolve(audioBlob); + }; + + this.mediaRecorder.stop(); + this.isRecording = false; + }); + } + + /** + * 取消录音 + */ + cancel() { + if (this.mediaRecorder && this.isRecording) { + this.mediaRecorder.stop(); + } + this.cleanup(); + this.isRecording = false; + } + + /** + * 清理资源 + */ + cleanup() { + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + this.mediaRecorder = null; + this.audioChunks = []; + } + + /** + * 检查浏览器是否支持录音 + * @returns {boolean} + */ + static isSupported() { + return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + } +} + +/** + * 获取 MediaRecorder 支持的音频格式 + */ +function getSupportedMimeType() { + const types = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/mp4', + 'audio/mpeg' + ]; + + for (const type of types) { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } + return 'audio/webm'; +} + +/** + * 测试 STT API + * @returns {Promise} + */ +export async function testSttApi() { + const config = getVoiceApiConfig(); + + if (!config.stt.url || !config.stt.key) { + throw new Error('请先填写 STT API 地址和密钥'); + } + + console.log('[可乐] 开始 STT 测试...'); + console.log('[可乐] STT 配置:', { + url: config.stt.url, + model: config.stt.model, + keyLength: config.stt.key?.length || 0 + }); + + // 创建测试音频 (1.5秒,包含一些变化的音调模拟语音) + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + const destination = audioContext.createMediaStreamDestination(); + + oscillator.connect(gainNode); + gainNode.connect(destination); + + // 模拟语音的频率变化 + oscillator.frequency.setValueAtTime(200, audioContext.currentTime); + oscillator.frequency.linearRampToValueAtTime(400, audioContext.currentTime + 0.5); + oscillator.frequency.linearRampToValueAtTime(300, audioContext.currentTime + 1); + oscillator.frequency.linearRampToValueAtTime(350, audioContext.currentTime + 1.5); + + // 音量包络 + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.5, audioContext.currentTime + 0.3); + gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 1.2); + gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1.5); + + oscillator.start(); + + const mimeType = getSupportedMimeType(); + console.log('[可乐] 录制音频格式:', mimeType); + + const recorder = new MediaRecorder(destination.stream, { mimeType }); + const chunks = []; + + return new Promise((resolve, reject) => { + recorder.ondataavailable = e => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + recorder.onstop = async () => { + oscillator.stop(); + audioContext.close(); + + const blob = new Blob(chunks, { type: mimeType }); + console.log('[可乐] 测试音频大小:', blob.size, 'bytes'); + + if (blob.size < 100) { + reject(new Error('测试音频生成失败')); + return; + } + + try { + // speechToText 会自动转换为 WAV 格式 + const result = await speechToText(blob); + console.log('[可乐] STT 测试结果:', result); + resolve(true); + } catch (err) { + reject(err); + } + }; + + recorder.start(100); + // 录制 1.5 秒 + setTimeout(() => recorder.stop(), 1500); + }); +} + +/** + * 测试 TTS API + * @returns {Promise} + */ +export async function testTtsApi() { + const config = getVoiceApiConfig(); + + if (!config.tts.url || !config.tts.key) { + throw new Error('请先填写 TTS API 地址和密钥'); + } + + return await textToSpeech('测试语音合成'); +} diff --git a/voice-call.js b/voice-call.js index 47ad529..8dc8bb4 100644 --- a/voice-call.js +++ b/voice-call.js @@ -106,6 +106,11 @@ function showCallPage() { if (chatEl) { chatEl.classList.add('hidden'); } + // 隐藏输入框 + const inputAreaEl = document.getElementById('wechat-voice-call-input-area'); + if (inputAreaEl) { + inputAreaEl.classList.add('hidden'); + } const messagesEl = document.getElementById('wechat-voice-call-messages'); if (messagesEl) { messagesEl.innerHTML = ''; @@ -243,6 +248,11 @@ function onCallConnected() { if (chatEl) { chatEl.classList.remove('hidden'); } + // 显示输入框 + const inputAreaEl = document.getElementById('wechat-voice-call-input-area'); + if (inputAreaEl) { + inputAreaEl.classList.remove('hidden'); + } // 切换到通话中按钮(隐藏来电按钮,显示通话控制按钮) const incomingActionsEl = document.getElementById('wechat-voice-call-incoming-actions');