Add files via upload

This commit is contained in:
Cola-Echo
2025-12-31 04:04:45 +08:00
committed by GitHub
parent 713f2211d2
commit fa1b9c111b
14 changed files with 3800 additions and 60 deletions

125
ai.js
View File

@@ -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') {

277
audio-storage.js Normal file
View File

@@ -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<number>} 保存的记录 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<Array>} 保存的记录 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<Array>} 语音记录数组
*/
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<Array>} 语音记录数组
*/
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<Object>} 语音记录
*/
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<HTMLAudioElement>} 音频元素
*/
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<Object>} 统计信息
*/
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);
};
});
}

View File

@@ -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();

70
chat.js
View File

@@ -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 = `<svg class="wechat-call-record-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>`;
let realVoiceCallRecordHTML;
if (isDuration) {
// 已接通:显示实时语音时长
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">实时语音 ${callInfo}</span>
</div>
`;
} else if (isCancelled) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">已取消</span>
</div>
`;
} else if (isRejected) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record wechat-call-rejected">
${micIconSVG}
<span class="wechat-call-record-text">${callInfo}</span>
</div>
`;
} else if (isTimeout) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">对方已取消</span>
</div>
`;
} else {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">${escapeHtml(callInfo)}</span>
</div>
`;
}
if (msg.role === 'user') {
html += `<div class="wechat-message self" data-msg-index="${index}" data-msg-role="user"><div class="wechat-message-avatar">${getUserAvatarHTML()}</div><div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${realVoiceCallRecordHTML}</div></div></div>`;
} else {
html += `<div class="wechat-message" data-msg-index="${index}" data-msg-role="assistant"><div class="wechat-message-avatar">${avatarContent}</div><div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${realVoiceCallRecordHTML}</div></div></div>`;
}
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()

View File

@@ -120,18 +120,24 @@ export function getMemePromptTemplate() {
return `##【必须使用】表情包功能
【重要】你【必须】经常发送表情包每2-3条回复至少发一个表情包
使用规则:
- 格式:<meme>表情名称</meme>
- 只需要填写表情名称不需要填写文件ID和扩展名
- 只能从下面列表选择,不能编造名称
★★★ 表情包标签格式(必须严格遵守)★★★
格式:<meme>表情名称</meme>
- 必须是成对标签:开始标签<meme>和结束标签</meme>缺一不可
- 表情名称必须从下面列表选择,不能编造
- 不需要填写文件ID和扩展名只填表情名称
【绝对禁止 - 最重要的规则!】
【绝对禁止 - 最重要的规则!违反会导致显示错误!
<meme>标签前后【绝对不能】有任何其他文字!必须用 ||| 分隔!
× 错误:好想你<meme>xxx</meme> ← 绝对禁止!标签和文字混在一起!
× 错误:<meme>xxx</meme>哈哈绝对禁止!标签后面有文字
× 错误:我很开心<meme>xxx</meme>你呢绝对禁止!标签夹在文字中间
√ 正确:好想你|||<meme>xxx</meme> ← 用|||分开,标签独立
√ 正确<meme>xxx</meme>|||哈哈哈 ← 标签独立一条
× 致命错误:好想你<meme>xxx</meme> ← 禁止!标签和文字粘在一起
× 致命错误:<meme>xxx</meme>哈哈 ← 禁止!标签后面有文字
× 致命错误:我很开心<meme>xxx</meme>你呢禁止!标签夹在文字中间
× 致命错误<meme>xxx ← 禁止!缺少结束标签</meme>
× 致命错误xxx</meme> ← 禁止!缺少开始标签<meme>
√ 正确写法:好想你|||<meme>小狗摇尾巴</meme> ← 用|||分开!
√ 正确写法:<meme>喜欢你</meme>|||我真的好喜欢你 ← 标签独立!
√ 正确写法:哈哈|||<meme>小熊跳舞</meme>|||你太搞笑了
可用表情包列表:
[
@@ -143,7 +149,7 @@ ${displayNames.join('\n')}
哈哈哈笑死|||<meme>小熊跳舞</meme>|||你太搞笑了
<meme>喜欢你</meme>|||我真的好喜欢你
记住:表情包让聊天更生动,【必须】经常使用!但<meme>标签必须独立`;
★重要★:<meme>和</meme>必须成对出现!标签必须用|||与文字分开`;
}
// 保留旧变量名以兼容,但实际使用时应调用 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,

273
main.js
View File

@@ -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 = '<div class="wechat-history-empty"><div class="wechat-history-empty-icon">📭</div><div>请先选择联系人</div></div>';
return;
}
// 显示加载状态
contentEl.innerHTML = '<div class="wechat-history-empty"><div>加载中...</div></div>';
try {
const recordings = await getVoiceRecordingsByContact(contactIndex);
if (!recordings || recordings.length === 0) {
contentEl.innerHTML = `
<div class="wechat-history-empty">
<div class="wechat-history-empty-icon" style="color: #07c160;">
<svg viewBox="0 0 24 24" width="48" height="48">
<path d="M12 1a4 4 0 0 0-4 4v7a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>暂无语音回放记录</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-top: 8px;">实时语音通话结束后可选择保存语音</div>
</div>
`;
return;
}
// 按保存时间倒序排列
const sortedRecordings = [...recordings].sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0));
let html = '<div class="wechat-voice-playback-list">';
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 += `
<div class="wechat-voice-playback-card" data-id="${recording.id}">
<div class="wechat-voice-playback-card-header">
<span class="wechat-voice-playback-time">${escapeHtml(savedTime)}</span>
<div class="wechat-voice-playback-actions">
<span class="wechat-voice-playback-duration">${durationStr}</span>
<button class="wechat-voice-playback-delete" data-id="${recording.id}" title="删除">×</button>
</div>
</div>
<div class="wechat-voice-playback-content">
<div class="wechat-voice-playback-text">${escapeHtml(recording.text || '')}</div>
<button class="wechat-voice-playback-btn" data-id="${recording.id}" title="播放">
<svg viewBox="0 0 24 24" width="20" height="20"><polygon points="5,3 19,12 5,21" fill="currentColor"/></svg>
</button>
</div>
</div>
`;
}
html += '</div>';
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 = '<svg viewBox="0 0 24 24" width="20" height="20"><rect x="6" y="4" width="4" height="16" fill="currentColor"/><rect x="14" y="4" width="4" height="16" fill="currentColor"/></svg>';
await playVoiceRecording(id);
} catch (err) {
console.error('[可乐] 播放语音失败:', err);
showToast('播放失败', '⚠️');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20"><polygon points="5,3 19,12 5,21" fill="currentColor"/></svg>';
}
});
});
// 绑定删除按钮事件
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 = `
<div class="wechat-history-empty">
<div class="wechat-history-empty-icon">⚠️</div>
<div>加载失败</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">${escapeHtml(err.message || '')}</div>
</div>
`;
}
}
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();
}

View File

@@ -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消息和撤回消息
// 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算
// 注意:包含 ||| 或 <meme> 的消息在渲染时会被拆分成多条可视消息,需要正确计算
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);
// 检查是否包含 ||| 或 <meme> 标签(这些会导致消息被分割显示)
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;
}

View File

@@ -228,15 +228,16 @@ export function generatePhoneHTML() {
<div class="wechat-func-item" data-func="photo"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/><path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>照片</span></div>
<div class="wechat-func-item" data-func="voicecall"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 005 5L15 13l5 2v4a2 2 0 01-2 2A16 16 0 013 6a2 2 0 012-2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></div><span>语音通话</span></div>
<div class="wechat-func-item" data-func="videocall"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="13" height="12" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M22 8l-7 4 7 4V8z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>视频通话</span></div>
<div class="wechat-func-item" data-func="realvoice"><div class="wechat-func-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>实时语音</span></div>
<div class="wechat-func-item" data-func="location"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="9" r="2.5" fill="currentColor"/></svg></div><span>位置</span></div>
<div class="wechat-func-item" data-func="redpacket"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="4" y="2" width="16" height="20" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M4 8h16" stroke="currentColor" stroke-width="1.5"/></svg></div><span>红包</span></div>
<div class="wechat-func-item" data-func="gift"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M12 8v13M3 12h18" stroke="currentColor" stroke-width="1.5"/><path d="M12 8c-2-4-6-4-6 0s4 0 6 0c2 0 6-4 6 0s-4 4-6 0" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>礼物</span></div>
<div class="wechat-func-item" data-func="transfer"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M2 10h20" stroke="currentColor" stroke-width="1.5"/><path d="M6 15h4M14 15h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>转账</span></div>
<div class="wechat-func-item" data-func="multi"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>多条消息</span></div>
</div>
</div>
<div class="wechat-func-page" data-page="1">
<div class="wechat-func-grid">
<div class="wechat-func-item" data-func="multi"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>多条消息</span></div>
<div class="wechat-func-item" data-func="voice"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音输入</span></div>
<div class="wechat-func-item" data-func="listen"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M3 18v-6a9 9 0 0118 0v6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3v5z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3v5z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>一起听</span></div>
<div class="wechat-func-item" data-func="music"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>音乐</span></div>
@@ -292,6 +293,7 @@ export function generatePhoneHTML() {
${generateModalsHTML(settings)}
${generateVoiceCallPageHTML()}
${generateVideoCallPageHTML()}
${generateRealVoiceCallPageHTML()}
${generateMusicPanelHTML()}
${generateListenTogetherHTML()}
${generateMomentsPageHTML()}
@@ -767,6 +769,7 @@ function generateServicePageHTML(settings) {
<div class="wechat-service-section-title">AI功能</div>
<div class="wechat-service-grid">
<div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div>
<div class="wechat-service-item" data-service="voice-api"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音API</span></div>
</div>
</div>
<div class="wechat-service-section">
@@ -802,6 +805,77 @@ function generateServicePageHTML(settings) {
</div>
</div>
</div>
<!-- 语音 API 设置面板 -->
<div class="wechat-service-panel hidden" id="wechat-voice-api-panel">
<div class="wechat-panel-header">
<span class="wechat-panel-title">语音 API 设置</span>
<button class="wechat-panel-close" data-panel="wechat-voice-api-panel">×</button>
</div>
<div style="padding: 16px; max-height: 70vh; overflow-y: auto;">
<div style="font-size: 13px; font-weight: bold; color: #00bcd4; margin-bottom: 10px;">语音识别 (STT)</div>
<div style="font-size: 11px; color: var(--wechat-text-secondary); margin-bottom: 8px;">将语音转换为文字</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 地址</div>
<input type="text" class="wechat-settings-input" id="wechat-stt-api-url" placeholder="https://api.example.com/v1/audio/transcriptions" value="${settings.sttApiUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 密钥</div>
<div class="wechat-settings-input-wrapper">
<input type="password" class="wechat-settings-input" id="wechat-stt-api-key" placeholder="sk-..." value="${settings.sttApiKey || ''}" style="width: 100%; box-sizing: border-box;">
<button class="wechat-settings-eye-btn" id="wechat-stt-key-toggle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/></svg></button>
</div>
</div>
<div style="margin-bottom: 16px;">
<div style="font-size: 12px; margin-bottom: 4px;">模型</div>
<input type="text" class="wechat-settings-input" id="wechat-stt-model" placeholder="whisper-1 或 iic/SenseVoiceSmall" value="${settings.sttModel || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="border-top: 1px solid var(--wechat-border); margin: 16px 0;"></div>
<div style="font-size: 13px; font-weight: bold; color: #009688; margin-bottom: 10px;">语音合成 (TTS)</div>
<div style="font-size: 11px; color: var(--wechat-text-secondary); margin-bottom: 8px;">将文字转换为语音</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 地址</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-api-url" placeholder="https://api.example.com/v1/audio/speech" value="${settings.ttsApiUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 密钥</div>
<div class="wechat-settings-input-wrapper">
<input type="password" class="wechat-settings-input" id="wechat-tts-api-key" placeholder="sk-..." value="${settings.ttsApiKey || ''}" style="width: 100%; box-sizing: border-box;">
<button class="wechat-settings-eye-btn" id="wechat-tts-key-toggle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/></svg></button>
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">模型</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-model" placeholder="gemini-2.5-flash-preview-tts / tts-1" value="${settings.ttsModel || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">音色</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-voice" placeholder="achird / alloy / nova" value="${settings.ttsVoice || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="display: flex; gap: 10px; margin-bottom: 12px;">
<div style="flex: 1;">
<div style="font-size: 12px; margin-bottom: 4px;">语速</div>
<input type="number" class="wechat-settings-input" id="wechat-tts-speed" placeholder="1.0" value="${settings.ttsSpeed || 1}" min="0.5" max="2" step="0.1" style="width: 100%; box-sizing: border-box;">
</div>
<div style="flex: 1;">
<div style="font-size: 12px; margin-bottom: 4px;">情感</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-emotion" placeholder="默认" value="${settings.ttsEmotion || '默认'}" style="width: 100%; box-sizing: border-box;">
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">代理 URL <span style="color: #999; font-weight: normal;">(MiniMax 需要)</span></div>
<input type="text" class="wechat-settings-input" id="wechat-tts-proxy-url" placeholder="http://你的服务器:3001" value="${settings.ttsProxyUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="display: flex; gap: 10px; margin-top: 16px;">
<button class="wechat-btn wechat-btn-small" id="wechat-voice-api-test-stt" style="flex: 1; background: #00bcd4; color: white;">测试 STT</button>
<button class="wechat-btn wechat-btn-small" id="wechat-voice-api-test-tts" style="flex: 1; background: #009688; color: white;">测试 TTS</button>
</div>
<button class="wechat-btn wechat-btn-primary wechat-btn-block" id="wechat-voice-api-save" style="margin-top: 10px;">保存配置</button>
<div id="wechat-voice-api-status" style="font-size: 12px; color: var(--wechat-text-secondary); margin-top: 8px; text-align: center;"></div>
</div>
</div>
</div>
</div>
`;
@@ -944,6 +1018,23 @@ function generateModalsHTML(settings) {
</div>
</div>
</div>
<!-- 语音回放选择弹窗 -->
<div id="wechat-voice-save-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="position: relative; max-width: 400px;">
<button class="wechat-modal-close-x" id="wechat-voice-save-cancel" title="关闭">×</button>
<div class="wechat-modal-title">保存语音回放</div>
<div class="wechat-voice-save-hint" style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 12px;">
选择想保留的语音,以后可以在聊天记录中回放
</div>
<div class="wechat-voice-save-list" id="wechat-voice-save-list" style="max-height: 300px; overflow-y: auto;">
<!-- 语音列表将动态生成 -->
</div>
<div class="wechat-modal-actions" style="margin-top: 16px; display: flex; gap: 10px;">
<button class="wechat-btn" id="wechat-voice-save-skip" style="flex: 1;">不保存</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-voice-save-confirm" style="flex: 1;">保存选中</button>
</div>
</div>
</div>
`;
}
@@ -969,12 +1060,14 @@ function generateVoiceCallPageHTML() {
<!-- 通话中对话框 -->
<div class="wechat-voice-call-chat hidden" id="wechat-voice-call-chat">
<div class="wechat-voice-call-messages" id="wechat-voice-call-messages"></div>
<div class="wechat-voice-call-input-area">
<input type="text" class="wechat-voice-call-input" id="wechat-voice-call-input" placeholder="输入文字...">
<button class="wechat-voice-call-send" id="wechat-voice-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<!-- 通话输入框(独立于对话框,放在按钮上方) -->
<div class="wechat-voice-call-input-area hidden" id="wechat-voice-call-input-area">
<input type="text" class="wechat-voice-call-input" id="wechat-voice-call-input" placeholder="输入文字...">
<button class="wechat-voice-call-send" id="wechat-voice-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<!-- 来电接听按钮AI发起时显示 -->
@@ -1050,12 +1143,14 @@ function generateVideoCallPageHTML() {
<!-- 通话中对话框 -->
<div class="wechat-video-call-chat hidden" id="wechat-video-call-chat">
<div class="wechat-video-call-messages" id="wechat-video-call-messages"></div>
<div class="wechat-video-call-input-area">
<input type="text" class="wechat-video-call-input" id="wechat-video-call-input" placeholder="输入文字...">
<button class="wechat-video-call-send" id="wechat-video-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<!-- 通话输入框(独立于对话框,放在按钮上方) -->
<div class="wechat-video-call-input-area hidden" id="wechat-video-call-input-area">
<input type="text" class="wechat-video-call-input" id="wechat-video-call-input" placeholder="输入文字...">
<button class="wechat-video-call-send" id="wechat-video-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<!-- 底部操作栏 -->
@@ -1113,6 +1208,82 @@ function generateVideoCallPageHTML() {
`;
}
// 实时语音通话页面 HTML
function generateRealVoiceCallPageHTML() {
return `
<!-- 实时语音通话页面 -->
<div id="wechat-real-voice-call-page" class="wechat-real-voice-call-page hidden">
<div class="wechat-real-voice-call-header">
<button class="wechat-real-voice-call-minimize" id="wechat-real-voice-call-minimize">
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M9 3v18M3 9h6" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<span class="wechat-real-voice-call-time hidden" id="wechat-real-voice-call-time">00:00</span>
<span style="width: 24px;"></span>
</div>
<div class="wechat-real-voice-call-content">
<div class="wechat-real-voice-call-avatar" id="wechat-real-voice-call-avatar"></div>
<div class="wechat-real-voice-call-name" id="wechat-real-voice-call-name"></div>
<div class="wechat-real-voice-call-status" id="wechat-real-voice-call-status">等待对方接受邀请</div>
</div>
<!-- 通话中消息显示区域 -->
<div class="wechat-real-voice-call-chat hidden" id="wechat-real-voice-call-chat">
<div class="wechat-real-voice-call-messages" id="wechat-real-voice-call-messages"></div>
</div>
<!-- 说话按钮区域 -->
<div class="wechat-real-voice-call-talk-area hidden" id="wechat-real-voice-call-talk-area">
<div class="wechat-real-voice-call-talk-btn" id="wechat-real-voice-call-talk-btn">点击 说话</div>
<div class="wechat-real-voice-call-talk-hint">点击开始,再点击发送</div>
<!-- 文字输入区域(不支持录音时使用) -->
<div class="wechat-real-voice-call-text-input-area" id="wechat-real-voice-call-text-input-area">
<input type="text" class="wechat-real-voice-call-text-input" id="wechat-real-voice-call-text-input" placeholder="打字说话...">
<button class="wechat-real-voice-call-text-send" id="wechat-real-voice-call-text-send">发送</button>
</div>
</div>
<!-- 来电接听按钮AI发起时显示 -->
<div class="wechat-real-voice-call-incoming-actions hidden" id="wechat-real-voice-call-incoming-actions">
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-reject">
<div class="wechat-real-voice-call-action-btn reject">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" fill="currentColor"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">拒绝</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-accept">
<div class="wechat-real-voice-call-action-btn accept">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">接听</span>
</div>
</div>
<!-- 通话中操作按钮(接通后显示) -->
<div class="wechat-real-voice-call-actions hidden" id="wechat-real-voice-call-actions">
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-mute">
<div class="wechat-real-voice-call-action-btn">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">静音</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-hangup">
<div class="wechat-real-voice-call-action-btn hangup">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" fill="currentColor"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">挂断</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-speaker">
<div class="wechat-real-voice-call-action-btn">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M11 5L6 9H2v6h4l5 4V5z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">扬声器</span>
</div>
</div>
</div>
`;
}
// 朋友圈页面 HTML
function generateMomentsPageHTML() {
return `
@@ -1654,11 +1825,12 @@ function generateHistoryPageHTML() {
<div style="width: 24px;"></div>
</div>
<!-- 个标签按钮 -->
<!-- 个标签按钮 -->
<div class="wechat-history-tabs">
<button class="wechat-history-tab active" data-tab="listen">一起听</button>
<button class="wechat-history-tab" data-tab="voice">语音通话</button>
<button class="wechat-history-tab" data-tab="video">视频通话</button>
<button class="wechat-history-tab wechat-history-tab-green" data-tab="playback">语音回放</button>
<button class="wechat-history-tab wechat-history-tab-pink" data-tab="toy">心动瞬间</button>
</div>

1193
real-voice-call.js Normal file

File diff suppressed because it is too large Load Diff

834
style.css
View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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');

731
voice-api.js Normal file
View File

@@ -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<string>} 识别的文字
*/
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>} 音频 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) {
// 修正 URLMiniMax 使用 /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<HTMLAudioElement>} 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<void>}
*/
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<Blob>} 录音数据
*/
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<boolean>}
*/
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<Blob>}
*/
export async function testTtsApi() {
const config = getVoiceApiConfig();
if (!config.tts.url || !config.tts.key) {
throw new Error('请先填写 TTS API 地址和密钥');
}
return await textToSpeech('测试语音合成');
}

View File

@@ -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');