mirror of
https://github.com/Cola-Echo/Cola.git
synced 2026-06-06 03:35:50 +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 || '...';
|
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(使用专门的视频通话提示词,包含场景描述)
|
// 视频通话中调用 AI(使用专门的视频通话提示词,包含场景描述)
|
||||||
// initiator: 'user' 表示用户打给AI,'ai' 表示AI打给用户
|
// initiator: 'user' 表示用户打给AI,'ai' 表示AI打给用户
|
||||||
export async function callVideoAI(contact, userMessage, callMessages = [], initiator = 'user') {
|
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 { isInGroupChat, sendGroupMessage, sendGroupPhotoMessage, sendGroupBatchMessages, getCurrentGroupIndex, appendGroupMessage, showGroupTypingIndicator, hideGroupTypingIndicator, callGroupAI, enforceGroupChatMemberLimit, appendGroupMusicCardMessage } from './group-chat.js';
|
||||||
import { startVoiceCall } from './voice-call.js';
|
import { startVoiceCall } from './voice-call.js';
|
||||||
import { startVideoCall } from './video-call.js';
|
import { startVideoCall } from './video-call.js';
|
||||||
|
import { startRealVoiceCall } from './real-voice-call.js';
|
||||||
import { showMusicPanel, initMusicEvents } from './music.js';
|
import { showMusicPanel, initMusicEvents } from './music.js';
|
||||||
import { showRedPacketPage } from './red-packet.js';
|
import { showRedPacketPage } from './red-packet.js';
|
||||||
import { showTransferPage } from './transfer.js';
|
import { showTransferPage } from './transfer.js';
|
||||||
@@ -656,6 +657,14 @@ function handleFuncItemClick(func) {
|
|||||||
hideFuncPanel();
|
hideFuncPanel();
|
||||||
startVideoCall();
|
startVideoCall();
|
||||||
return;
|
return;
|
||||||
|
case 'realvoice':
|
||||||
|
hideFuncPanel();
|
||||||
|
if (isInGroupChat()) {
|
||||||
|
showToast('群聊暂不支持实时语音', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startRealVoiceCall();
|
||||||
|
return;
|
||||||
case 'music':
|
case 'music':
|
||||||
hideFuncPanel();
|
hideFuncPanel();
|
||||||
showMusicPanel();
|
showMusicPanel();
|
||||||
|
|||||||
70
chat.js
70
chat.js
@@ -952,6 +952,71 @@ export function renderChatHistory(contact, chatHistory, indexOffset = 0) {
|
|||||||
return;
|
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(/^\[视频通话[::](.+?)\]$/);
|
const videoCallRecordMatch = (msg.content || '').match(/^\[视频通话[::](.+?)\]$/);
|
||||||
if (msg.isVideoCallRecord || videoCallRecordMatch) {
|
if (msg.isVideoCallRecord || videoCallRecordMatch) {
|
||||||
@@ -1534,6 +1599,11 @@ export function appendMessage(role, content, contact, isVoice = false, quote = n
|
|||||||
const messageDiv = document.createElement('div');
|
const messageDiv = document.createElement('div');
|
||||||
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
|
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 firstChar = contact?.name ? contact.name.charAt(0) : '?';
|
||||||
const avatarContent = role === 'user'
|
const avatarContent = role === 'user'
|
||||||
? getUserAvatarHTML()
|
? getUserAvatarHTML()
|
||||||
|
|||||||
46
config.js
46
config.js
@@ -120,18 +120,24 @@ export function getMemePromptTemplate() {
|
|||||||
return `##【必须使用】表情包功能
|
return `##【必须使用】表情包功能
|
||||||
【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包!
|
【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包!
|
||||||
|
|
||||||
使用规则:
|
★★★ 表情包标签格式(必须严格遵守)★★★
|
||||||
- 格式:<meme>表情名称</meme>
|
格式:<meme>表情名称</meme>
|
||||||
- 只需要填写表情名称,不需要填写文件ID和扩展名
|
- 必须是成对标签:开始标签<meme>和结束标签</meme>缺一不可
|
||||||
- 只能从下面列表选择,不能编造名称
|
- 表情名称必须从下面列表选择,不能编造
|
||||||
|
- 不需要填写文件ID和扩展名,只填表情名称
|
||||||
|
|
||||||
【绝对禁止 - 最重要的规则!】
|
【绝对禁止 - 最重要的规则!违反会导致显示错误!】
|
||||||
<meme>标签前后【绝对不能】有任何其他文字!必须用 ||| 分隔!
|
<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>喜欢你</meme>|||我真的好喜欢你
|
||||||
|
|
||||||
记住:表情包让聊天更生动,【必须】经常使用!但<meme>标签必须独立!`;
|
★重要★:<meme>和</meme>必须成对出现!标签必须用|||与文字分开!`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate()
|
// 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate()
|
||||||
@@ -227,6 +233,24 @@ export const defaultSettings = {
|
|||||||
groupSelectedModel: '',
|
groupSelectedModel: '',
|
||||||
groupModelList: [],
|
groupModelList: [],
|
||||||
|
|
||||||
|
// ========== 语音功能 API 配置 ==========
|
||||||
|
// STT (语音转文字)
|
||||||
|
sttApiUrl: '',
|
||||||
|
sttApiKey: '',
|
||||||
|
sttModel: '',
|
||||||
|
|
||||||
|
// TTS (文字转语音)
|
||||||
|
ttsApiUrl: '',
|
||||||
|
ttsApiKey: '',
|
||||||
|
ttsModel: '', // 模型
|
||||||
|
ttsVoice: '', // 音色
|
||||||
|
ttsSpeed: 1, // 语速
|
||||||
|
ttsEmotion: '默认', // 情感
|
||||||
|
ttsProxyUrl: '', // TTS 代理 URL(用于解决 CORS 问题,如 MiniMax)
|
||||||
|
|
||||||
|
// 实时语音通话开关
|
||||||
|
realVoiceEnabled: true,
|
||||||
|
|
||||||
// 上下文设置
|
// 上下文设置
|
||||||
contextEnabled: false,
|
contextEnabled: false,
|
||||||
contextLevel: 5,
|
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 { initGiftEvents } from './gift.js';
|
||||||
import { initCropper } from './cropper.js';
|
import { initCropper } from './cropper.js';
|
||||||
import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js';
|
import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js';
|
||||||
|
import { testSttApi, testTtsApi } from './voice-api.js';
|
||||||
|
import { getVoiceRecordingsByContact, deleteVoiceRecording, playVoiceRecording } from './audio-storage.js';
|
||||||
|
|
||||||
// ========== 历史记录功能 ==========
|
// ========== 历史记录功能 ==========
|
||||||
let currentHistoryTab = 'listen';
|
let currentHistoryTab = 'listen';
|
||||||
@@ -137,6 +139,12 @@ function renderHistoryContent(contact, tabType) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 语音回放使用专门的渲染函数
|
||||||
|
if (tabType === 'playback') {
|
||||||
|
renderVoicePlaybackContent(contact);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const context = window.SillyTavern?.getContext?.() || {};
|
const context = window.SillyTavern?.getContext?.() || {};
|
||||||
const userName = context.name1 || '用户';
|
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) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -1679,6 +1811,13 @@ function bindEvents() {
|
|||||||
return;
|
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 || '该';
|
const label = item.querySelector('span')?.textContent || '该';
|
||||||
showToast(`"${label}" 功能开发中...`, 'info');
|
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();
|
refreshContactsList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -688,6 +688,14 @@ export function bindMessageBubbleEvents(container) {
|
|||||||
|
|
||||||
// 获取真实的消息索引(排除时间标签等)
|
// 获取真实的消息索引(排除时间标签等)
|
||||||
function getRealMsgIndex(container, msgElement) {
|
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 settings = getSettings();
|
||||||
const contact = settings.contacts[currentChatIndex];
|
const contact = settings.contacts[currentChatIndex];
|
||||||
if (!contact || !contact.chatHistory) return -1;
|
if (!contact || !contact.chatHistory) return -1;
|
||||||
@@ -699,7 +707,7 @@ function getRealMsgIndex(container, msgElement) {
|
|||||||
if (visualIndex < 0) return -1;
|
if (visualIndex < 0) return -1;
|
||||||
|
|
||||||
// 需要计算真实索引(chatHistory中可能包含marker消息和撤回消息)
|
// 需要计算真实索引(chatHistory中可能包含marker消息和撤回消息)
|
||||||
// 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算
|
// 注意:包含 ||| 或 <meme> 的消息在渲染时会被拆分成多条可视消息,需要正确计算
|
||||||
let realIndex = -1;
|
let realIndex = -1;
|
||||||
let visualCount = 0;
|
let visualCount = 0;
|
||||||
|
|
||||||
@@ -712,9 +720,10 @@ function getRealMsgIndex(container, msgElement) {
|
|||||||
let visualMsgCount = 1;
|
let visualMsgCount = 1;
|
||||||
const content = msg.content || '';
|
const content = msg.content || '';
|
||||||
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
|
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
|
||||||
if (!isSpecial && content.indexOf('|||') >= 0) {
|
// 检查是否包含 ||| 或 <meme> 标签(这些会导致消息被分割显示)
|
||||||
// 按 ||| 分割后有多少个非空部分
|
if (!isSpecial && (content.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(content))) {
|
||||||
const parts = content.split('|||').map(p => p.trim()).filter(p => p);
|
// 使用 splitAIMessages 计算实际分割数量
|
||||||
|
const parts = splitAIMessages(content).filter(p => p && p.trim());
|
||||||
visualMsgCount = parts.length || 1;
|
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="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="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="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="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="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="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="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>
|
</div>
|
||||||
<div class="wechat-func-page" data-page="1">
|
<div class="wechat-func-page" data-page="1">
|
||||||
<div class="wechat-func-grid">
|
<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="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="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>
|
<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)}
|
${generateModalsHTML(settings)}
|
||||||
${generateVoiceCallPageHTML()}
|
${generateVoiceCallPageHTML()}
|
||||||
${generateVideoCallPageHTML()}
|
${generateVideoCallPageHTML()}
|
||||||
|
${generateRealVoiceCallPageHTML()}
|
||||||
${generateMusicPanelHTML()}
|
${generateMusicPanelHTML()}
|
||||||
${generateListenTogetherHTML()}
|
${generateListenTogetherHTML()}
|
||||||
${generateMomentsPageHTML()}
|
${generateMomentsPageHTML()}
|
||||||
@@ -767,6 +769,7 @@ function generateServicePageHTML(settings) {
|
|||||||
<div class="wechat-service-section-title">AI功能</div>
|
<div class="wechat-service-section-title">AI功能</div>
|
||||||
<div class="wechat-service-grid">
|
<div class="wechat-service-grid">
|
||||||
<div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div>
|
<div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div>
|
||||||
|
<div class="wechat-service-item" data-service="voice-api"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音API</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wechat-service-section">
|
<div class="wechat-service-section">
|
||||||
@@ -802,6 +805,77 @@ function generateServicePageHTML(settings) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -944,6 +1018,23 @@ function generateModalsHTML(settings) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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-chat hidden" id="wechat-voice-call-chat">
|
||||||
<div class="wechat-voice-call-messages" id="wechat-voice-call-messages"></div>
|
<div class="wechat-voice-call-messages" id="wechat-voice-call-messages"></div>
|
||||||
<div class="wechat-voice-call-input-area">
|
</div>
|
||||||
<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>
|
<div class="wechat-voice-call-input-area hidden" id="wechat-voice-call-input-area">
|
||||||
</button>
|
<input type="text" class="wechat-voice-call-input" id="wechat-voice-call-input" placeholder="输入文字...">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- 来电接听按钮(AI发起时显示) -->
|
<!-- 来电接听按钮(AI发起时显示) -->
|
||||||
@@ -1050,12 +1143,14 @@ function generateVideoCallPageHTML() {
|
|||||||
<!-- 通话中对话框 -->
|
<!-- 通话中对话框 -->
|
||||||
<div class="wechat-video-call-chat hidden" id="wechat-video-call-chat">
|
<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-messages" id="wechat-video-call-messages"></div>
|
||||||
<div class="wechat-video-call-input-area">
|
</div>
|
||||||
<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>
|
<div class="wechat-video-call-input-area hidden" id="wechat-video-call-input-area">
|
||||||
</button>
|
<input type="text" class="wechat-video-call-input" id="wechat-video-call-input" placeholder="输入文字...">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- 底部操作栏 -->
|
||||||
@@ -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
|
// 朋友圈页面 HTML
|
||||||
function generateMomentsPageHTML() {
|
function generateMomentsPageHTML() {
|
||||||
return `
|
return `
|
||||||
@@ -1654,11 +1825,12 @@ function generateHistoryPageHTML() {
|
|||||||
<div style="width: 24px;"></div>
|
<div style="width: 24px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 四个标签按钮 -->
|
<!-- 五个标签按钮 -->
|
||||||
<div class="wechat-history-tabs">
|
<div class="wechat-history-tabs">
|
||||||
<button class="wechat-history-tab active" data-tab="listen">一起听</button>
|
<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="voice">语音通话</button>
|
||||||
<button class="wechat-history-tab" data-tab="video">视频通话</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>
|
<button class="wechat-history-tab wechat-history-tab-pink" data-tab="toy">心动瞬间</button>
|
||||||
</div>
|
</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);
|
background: rgba(50, 50, 50, 0.8);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 4px 4px 4px 16px;
|
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 {
|
.wechat-voice-call-input {
|
||||||
@@ -5300,6 +5304,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
|
margin: 0 16px 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-video-call-input-area.hidden {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-video-call-input {
|
.wechat-video-call-input {
|
||||||
@@ -5467,6 +5476,573 @@
|
|||||||
transform: scale(1.05);
|
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 {
|
.wechat-music-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -11951,6 +12527,114 @@
|
|||||||
opacity: 1;
|
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 {
|
.wechat-toy-history-card {
|
||||||
background: linear-gradient(135deg, #fff5f8, #ffe4ec);
|
background: linear-gradient(135deg, #fff5f8, #ffe4ec);
|
||||||
@@ -12207,3 +12891,151 @@
|
|||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
box-shadow 0.3s ease;
|
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';
|
prompt = settings.customSummaryTemplate.trim() + '\n\n';
|
||||||
} else {
|
} else {
|
||||||
// 使用默认模板(纯对话记录模式)
|
// 使用默认模板(纯对话记录模式)
|
||||||
prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式。
|
prompt = `【重要】你必须且只能输出一个JSON对象,禁止输出任何其他内容。
|
||||||
|
|
||||||
【核心原则】
|
你的任务是将聊天记录整理成JSON格式。
|
||||||
- 原样保留:完整复制每一条对话,不做任何修改、润色或总结
|
|
||||||
- 格式统一:按"发言者: 内容"格式逐行记录
|
|
||||||
- 仅提取关键词:从对话中提取3-5个核心关键词用于检索触发
|
|
||||||
|
|
||||||
【输出格式要求】
|
【输出规则 - 必须严格遵守】
|
||||||
- 只输出一个JSON对象
|
1. 直接以 { 开头,以 } 结尾
|
||||||
- 不要使用markdown代码块
|
2. 禁止使用markdown代码块(禁止\`\`\`)
|
||||||
- 直接以 { 开头,以 } 结尾
|
3. 禁止输出任何解释、思考、前言
|
||||||
- keys: 3-5个能代表本次聊天核心内容的关键词(人名、地点、事件等)
|
4. 禁止在JSON前后添加任何文字
|
||||||
- content: 以"以下是线上聊天内容:"开头,然后原样复制对话记录,每条一行,格式为"发言者: 内容"
|
|
||||||
- comment: "${getCupName(cupNumber)}"
|
|
||||||
|
|
||||||
【JSON示例】
|
【JSON字段说明】
|
||||||
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"}
|
- "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;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用总结API
|
// 调用总结API
|
||||||
export async function callSummaryAPI(prompt) {
|
export async function callSummaryAPI(prompt, cupNumber = 1) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const apiUrl = settings.summaryApiUrl;
|
const apiUrl = settings.summaryApiUrl;
|
||||||
const apiKey = settings.summaryApiKey;
|
const apiKey = settings.summaryApiKey;
|
||||||
@@ -513,14 +513,16 @@ export async function callSummaryAPI(prompt) {
|
|||||||
const content = data.choices?.[0]?.message?.content || '';
|
const content = data.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
// 解析JSON
|
// 解析JSON
|
||||||
const parsed = parseJSONResponse(content);
|
const parsed = parseJSONResponse(content, cupNumber);
|
||||||
if (parsed) return parsed;
|
if (parsed) return parsed;
|
||||||
|
|
||||||
throw new Error('AI返回内容为空或无法解析');
|
throw new Error('AI返回内容为空或无法解析');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析JSON响应
|
// 解析JSON响应
|
||||||
function parseJSONResponse(content) {
|
function parseJSONResponse(content, cupNumber = 1) {
|
||||||
|
if (!content || !content.trim()) return null;
|
||||||
|
|
||||||
// 方法1: 直接解析
|
// 方法1: 直接解析
|
||||||
try {
|
try {
|
||||||
const result = JSON.parse(content);
|
const result = JSON.parse(content);
|
||||||
@@ -545,16 +547,26 @@ function parseJSONResponse(content) {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// 降级方案
|
// 方法4: 尝试修复常见的JSON格式问题
|
||||||
if (content && content.trim().length > 20) {
|
try {
|
||||||
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
|
// 替换中文冒号和引号
|
||||||
return {
|
let fixed = content
|
||||||
keys: [...new Set(words)].slice(0, 5),
|
.replace(/:/g, ':')
|
||||||
content: content.substring(0, 30000).replace(/```[\s\S]*?```/g, '').trim(),
|
.replace(/"/g, '"')
|
||||||
comment: '感情记录'
|
.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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +744,7 @@ export async function executeSummary() {
|
|||||||
// 为单个聊天生成总结
|
// 为单个聊天生成总结
|
||||||
updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...');
|
updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...');
|
||||||
const prompt = generateSummaryPrompt([chat], cupNumber);
|
const prompt = generateSummaryPrompt([chat], cupNumber);
|
||||||
const entry = await callSummaryAPI(prompt);
|
const entry = await callSummaryAPI(prompt, cupNumber);
|
||||||
|
|
||||||
// 保存到收藏
|
// 保存到收藏
|
||||||
saveEntryToFavorites(entry, cupNumber, lorebookName);
|
saveEntryToFavorites(entry, cupNumber, lorebookName);
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function showIncomingCallPage() {
|
|||||||
// 隐藏主界面元素,显示来电界面
|
// 隐藏主界面元素,显示来电界面
|
||||||
document.getElementById('wechat-video-call-center')?.classList.add('hidden');
|
document.getElementById('wechat-video-call-center')?.classList.add('hidden');
|
||||||
document.getElementById('wechat-video-call-chat')?.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');
|
document.getElementById('wechat-video-call-actions')?.classList.add('hidden');
|
||||||
incomingEl.classList.remove('hidden');
|
incomingEl.classList.remove('hidden');
|
||||||
|
|
||||||
@@ -165,8 +166,9 @@ function showCallPage() {
|
|||||||
timeEl.classList.add('hidden'); // 拨打中不显示计时
|
timeEl.classList.add('hidden'); // 拨打中不显示计时
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏对话框
|
// 隐藏对话框和输入框
|
||||||
document.getElementById('wechat-video-call-chat')?.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 &&
|
||||||
(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-incoming')?.classList.add('hidden');
|
||||||
document.getElementById('wechat-video-call-actions')?.classList.remove('hidden');
|
document.getElementById('wechat-video-call-actions')?.classList.remove('hidden');
|
||||||
|
|
||||||
// 显示对话框
|
// 显示对话框和输入框
|
||||||
document.getElementById('wechat-video-call-chat')?.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');
|
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) {
|
if (chatEl) {
|
||||||
chatEl.classList.add('hidden');
|
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');
|
const messagesEl = document.getElementById('wechat-voice-call-messages');
|
||||||
if (messagesEl) {
|
if (messagesEl) {
|
||||||
messagesEl.innerHTML = '';
|
messagesEl.innerHTML = '';
|
||||||
@@ -243,6 +248,11 @@ function onCallConnected() {
|
|||||||
if (chatEl) {
|
if (chatEl) {
|
||||||
chatEl.classList.remove('hidden');
|
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');
|
const incomingActionsEl = document.getElementById('wechat-voice-call-incoming-actions');
|
||||||
|
|||||||
Reference in New Issue
Block a user