mirror of
https://github.com/Cola-Echo/Cola.git
synced 2026-06-05 23:25:51 +00:00
Add files via upload
This commit is contained in:
125
ai.js
125
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') {
|
||||
|
||||
277
audio-storage.js
Normal file
277
audio-storage.js
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
70
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 = `<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()
|
||||
|
||||
46
config.js
46
config.js
@@ -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
273
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 = '<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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
200
phone-html.js
200
phone-html.js
@@ -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
1193
real-voice-call.js
Normal file
File diff suppressed because it is too large
Load Diff
834
style.css
834
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;
|
||||
}
|
||||
|
||||
68
summary.js
68
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);
|
||||
|
||||
@@ -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
731
voice-api.js
Normal 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) {
|
||||
// 修正 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<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('测试语音合成');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user