Compare commits

..

8 Commits

Author SHA1 Message Date
Cola-Echo
262611c736 Add files via upload 2026-01-02 16:10:57 +08:00
Cola-Echo
4a097a613b Add files via upload 2026-01-02 02:48:58 +08:00
Cola-Echo
8595a7c48d Add files via upload 2026-01-02 02:26:21 +08:00
Cola-Echo
f07e0914f0 Add files via upload 2025-12-31 15:04:18 +08:00
Cola-Echo
5068b46702 Add files via upload 2025-12-31 14:11:05 +08:00
Cola-Echo
40526f614d Add files via upload 2025-12-31 10:16:52 +08:00
Cola-Echo
f9b003e0dc Add files via upload 2025-12-31 04:21:54 +08:00
Cola-Echo
fa1b9c111b Add files via upload 2025-12-31 04:04:45 +08:00
20 changed files with 7958 additions and 104 deletions

136
ai.js
View File

@@ -258,6 +258,17 @@ export async function testApiConnection() {
}
}
// 测试指定 API 连接(接受参数)
export async function testConnection(apiUrl, apiKey, model) {
if (!apiUrl) {
throw new Error('请先配置 API 地址');
}
// 尝试获取模型列表来验证连接
await fetchModelListFromApi(apiUrl, apiKey);
return true;
}
// 获取模型列表
export async function fetchModelList() {
const config = getApiConfig();
@@ -971,6 +982,131 @@ ${voiceCallPrompt}`;
return data.choices?.[0]?.message?.content || '...';
}
// 实时语音通话中调用 AI纯文本输出不带任何格式标记
export async function callRealVoiceAI(contact, userMessage, callMessages = [], initiator = 'user') {
// 获取 API 配置
let apiUrl, apiKey, apiModel;
if (contact.useCustomApi) {
apiUrl = contact.customApiUrl || '';
apiKey = contact.customApiKey || '';
apiModel = contact.customModel || '';
const globalConfig = getApiConfig();
if (!apiUrl) apiUrl = globalConfig.url;
if (!apiKey) apiKey = globalConfig.key;
if (!apiModel) apiModel = globalConfig.model;
} else {
const globalConfig = getApiConfig();
apiUrl = globalConfig.url;
apiKey = globalConfig.key;
apiModel = globalConfig.model;
}
if (!apiUrl) {
throw new Error('请先配置 API 地址');
}
if (!apiModel) {
throw new Error('请先选择模型');
}
// 实时语音专用提示词(纯文本,无格式)
const realVoicePrompt = `你正在和用户进行实时语音通话。
【重要输出规则】
- 只输出你说出口的话,不要有任何其他内容
- 禁止使用小括号描述语气、动作、情绪
- 禁止使用方括号、尖括号等任何标记
- 禁止添加旁白、说明、注释
- 一次输出完整的回复,不需要分段
正确示例:
喂?在呢在呢,怎么突然打电话过来啦,是不是想我了?
错误示例(禁止):
喂?(好奇地)在呢~[开心]
【通话规则】
- 像真人打电话一样自然交流
- 符合你的角色设定和性格
- 积极与用户互动,根据话题自然展开对话
- 可以说的比较多,像真人聊天`;
// 构建系统提示词
const baseSystemPrompt = buildSystemPrompt(contact, { allowStickers: false, allowMusicShare: false, allowCallRequests: false });
const systemPrompt = `${baseSystemPrompt}
【当前场景:实时语音通话中】
${realVoicePrompt}`;
// 构建消息
const messages = [{ role: 'system', content: systemPrompt }];
// 添加聊天历史
const chatHistory = contact.chatHistory || [];
chatHistory.forEach(msg => {
if (msg.isRecalled) {
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: '[用户撤回了一条消息]'
});
return;
}
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
});
});
// 添加通话标记
if (initiator === 'ai') {
messages.push({ role: 'assistant', content: '[你主动拨打了实时语音,用户已接听]' });
} else {
messages.push({ role: 'user', content: '[用户发起了实时语音,你已接听]' });
}
// 添加通话历史
callMessages.forEach(msg => {
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
});
});
// 添加当前消息
messages.push({ role: 'user', content: userMessage });
const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetchWithRetry(
chatUrl,
{
method: 'POST',
headers: headers,
body: JSON.stringify({
model: apiModel,
messages: messages,
temperature: 1,
max_tokens: 500
})
},
{ maxRetries: 3 }
);
if (!response.ok) {
throw new Error(await formatApiError(response, {}));
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '...';
}
// 视频通话中调用 AI使用专门的视频通话提示词包含场景描述
// initiator: 'user' 表示用户打给AI'ai' 表示AI打给用户
export async function callVideoAI(contact, userMessage, callMessages = [], initiator = 'user') {

325
audio-storage.js Normal file
View File

@@ -0,0 +1,325 @@
/**
* 语音存储模块 - 使用 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);
};
});
}
/**
* 获取所有语音记录,按联系人分组
* @returns {Promise<Object>} { contactIndex: { count, totalDuration } }
*/
export async function getAllVoiceRecordingsGroupedByContact() {
await initAudioDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const records = request.result;
const grouped = {};
records.forEach(record => {
const idx = record.contactIndex;
if (!grouped[idx]) {
grouped[idx] = { count: 0, totalDuration: 0 };
}
grouped[idx].count++;
grouped[idx].totalDuration += record.duration || 0;
});
resolve(grouped);
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* 删除指定联系人的所有语音记录
* @param {number} contactIndex - 联系人索引
* @returns {Promise<number>} 删除的记录数量
*/
export async function deleteVoiceRecordingsByContact(contactIndex) {
const records = await getVoiceRecordingsByContact(contactIndex);
for (const record of records) {
await deleteVoiceRecording(record.id);
}
console.log(`[可乐] 已删除联系人 ${contactIndex}${records.length} 条语音记录`);
return records.length;
}

View File

@@ -8,6 +8,7 @@ import { sendMessage, sendPhotoMessage, sendBatchMessages, appendMusicCardMessag
import { isInGroupChat, sendGroupMessage, sendGroupPhotoMessage, sendGroupBatchMessages, getCurrentGroupIndex, appendGroupMessage, showGroupTypingIndicator, hideGroupTypingIndicator, callGroupAI, enforceGroupChatMemberLimit, appendGroupMusicCardMessage } from './group-chat.js';
import { startVoiceCall } from './voice-call.js';
import { startVideoCall } from './video-call.js';
import { startRealVoiceCall } from './real-voice-call.js';
import { showMusicPanel, initMusicEvents } from './music.js';
import { showRedPacketPage } from './red-packet.js';
import { showTransferPage } from './transfer.js';
@@ -656,6 +657,14 @@ function handleFuncItemClick(func) {
hideFuncPanel();
startVideoCall();
return;
case 'realvoice':
hideFuncPanel();
if (isInGroupChat()) {
showToast('群聊暂不支持实时语音', 'info');
return;
}
startRealVoiceCall();
return;
case 'music':
hideFuncPanel();
showMusicPanel();

101
chat.js
View File

@@ -753,6 +753,11 @@ export function openChat(contactIndex) {
// 加载联系人的聊天背景
loadContactBackground(contactIndex);
// 隐藏群聊专属菜单项,显示单聊专属菜单项
document.getElementById('wechat-menu-invite-member')?.classList.add('hidden');
document.getElementById('wechat-menu-block')?.classList.remove('hidden');
document.getElementById('wechat-menu-moments')?.classList.remove('hidden');
}
// 通过联系人ID打开聊天
@@ -886,9 +891,9 @@ export function renderChatHistory(contact, chatHistory, indexOffset = 0) {
return;
}
// 检查是否是通话记录消息
// 检查是否是通话记录消息(排除实时语音)
const callRecordMatch = (msg.content || '').match(/^\[通话记录[:](.+?)\]$/);
if (msg.isCallRecord || callRecordMatch) {
if ((msg.isCallRecord && !msg.isRealVoice) || callRecordMatch) {
const callInfo = callRecordMatch ? callRecordMatch[1] : '00:00';
const isDuration = /^\d{2}:\d{2}$/.test(callInfo);
const isCancelled = callInfo === '已取消';
@@ -952,6 +957,71 @@ export function renderChatHistory(contact, chatHistory, indexOffset = 0) {
return;
}
// 检查是否是实时语音通话记录消息
const realVoiceCallRecordMatch = (msg.content || '').match(/^\[实时语音[:](.+?)\]$/);
if (msg.isRealVoice || realVoiceCallRecordMatch) {
const callInfo = realVoiceCallRecordMatch ? realVoiceCallRecordMatch[1] : '00:00';
const isDuration = /^\d{2}:\d{2}$/.test(callInfo);
const isCancelled = callInfo === '已取消';
const isRejected = callInfo === '已拒绝' || callInfo === '对方已拒绝';
const isTimeout = callInfo === '对方已取消';
// 麦克风图标
const micIconSVG = `<svg class="wechat-call-record-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>`;
let realVoiceCallRecordHTML;
if (isDuration) {
// 已接通:显示实时语音时长
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">实时语音 ${callInfo}</span>
</div>
`;
} else if (isCancelled) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">已取消</span>
</div>
`;
} else if (isRejected) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record wechat-call-rejected">
${micIconSVG}
<span class="wechat-call-record-text">${callInfo}</span>
</div>
`;
} else if (isTimeout) {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">对方已取消</span>
</div>
`;
} else {
realVoiceCallRecordHTML = `
<div class="wechat-call-record wechat-real-voice-record">
${micIconSVG}
<span class="wechat-call-record-text">${escapeHtml(callInfo)}</span>
</div>
`;
}
if (msg.role === 'user') {
html += `<div class="wechat-message self" data-msg-index="${index}" data-msg-role="user"><div class="wechat-message-avatar">${getUserAvatarHTML()}</div><div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${realVoiceCallRecordHTML}</div></div></div>`;
} else {
html += `<div class="wechat-message" data-msg-index="${index}" data-msg-role="assistant"><div class="wechat-message-avatar">${avatarContent}</div><div class="wechat-message-content"><div class="wechat-bubble wechat-call-record-bubble">${realVoiceCallRecordHTML}</div></div></div>`;
}
lastTimestamp = msgTimestamp;
return;
}
// 检查是否是视频通话记录消息
const videoCallRecordMatch = (msg.content || '').match(/^\[视频通话[:](.+?)\]$/);
if (msg.isVideoCallRecord || videoCallRecordMatch) {
@@ -1406,7 +1476,8 @@ function getRealMsgIndexForVoice(container, msgElement) {
let visualMsgCount = 1;
const content = msg.content || '';
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
if (!isSpecial && content.indexOf('|||') >= 0) {
// 只有 assistant 消息才会被 ||| 分割显示
if (msg.role === 'assistant' && !isSpecial && content.indexOf('|||') >= 0) {
const parts = content.split('|||').map(p => p.trim()).filter(p => p);
visualMsgCount = parts.length || 1;
}
@@ -1534,6 +1605,11 @@ export function appendMessage(role, content, contact, isVoice = false, quote = n
const messageDiv = document.createElement('div');
messageDiv.className = `wechat-message ${role === 'user' ? 'self' : ''}`;
// 计算消息在chatHistory中的索引
const msgIndex = contact?.chatHistory ? contact.chatHistory.length - 1 : -1;
messageDiv.dataset.msgIndex = msgIndex;
messageDiv.dataset.msgRole = role === 'user' ? 'user' : 'assistant';
const firstChar = contact?.name ? contact.name.charAt(0) : '?';
const avatarContent = role === 'user'
? getUserAvatarHTML()
@@ -1947,14 +2023,14 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
}
}
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/);
const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) {
aiMsg = voiceMatch[1];
aiIsVoice = true;
}
// 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/);
const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) {
aiMsg = photoMatch[1];
aiIsPhoto = true;
@@ -2418,6 +2494,13 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
// 尝试触发语音/视频通话(随机触发+保底机制)
tryTriggerCallAfterChat(contactIndex);
// 检查其他联系人是否要主动发消息
import('./proactive-message.js').then(m => {
m.checkOtherContactsProactive(contact.id);
}).catch(err => {
console.error('[可乐] 主动消息检查失败:', err);
});
} catch (err) {
hideTypingIndicator();
console.error('[可乐] AI 调用失败:', err);
@@ -2520,14 +2603,14 @@ export async function sendStickerMessage(stickerUrl, description = '') {
if (!aiMsg.trim()) continue;
}
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/);
const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) {
aiMsg = voiceMatch[1];
aiIsVoice = true;
}
// 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/);
const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) {
aiMsg = photoMatch[1];
aiIsPhoto = true;
@@ -2967,14 +3050,14 @@ export async function sendPhotoMessage(description) {
if (!aiMsg.trim()) continue;
}
const voiceMatch = aiMsg.match(/^\[语音[:]\s*(.+?)\]$/);
const voiceMatch = aiMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) {
aiMsg = voiceMatch[1];
aiIsVoice = true;
}
// 解析AI照片格式 [照片:描述]
const photoMatch = aiMsg.match(/^\[照片[:]\s*(.+?)\]$/);
const photoMatch = aiMsg.match(/^\s*\[照片[:]\s*(.+?)\]\s*$/);
if (photoMatch) {
aiMsg = photoMatch[1];
aiIsPhoto = true;

View File

@@ -120,18 +120,24 @@ export function getMemePromptTemplate() {
return `##【必须使用】表情包功能
【重要】你【必须】经常发送表情包每2-3条回复至少发一个表情包
使用规则:
- 格式:<meme>表情名称</meme>
- 只需要填写表情名称不需要填写文件ID和扩展名
- 只能从下面列表选择,不能编造名称
★★★ 表情包标签格式(必须严格遵守)★★★
格式:<meme>表情名称</meme>
- 必须是成对标签:开始标签<meme>和结束标签</meme>缺一不可
- 表情名称必须从下面列表选择,不能编造
- 不需要填写文件ID和扩展名只填表情名称
【绝对禁止 - 最重要的规则!】
【绝对禁止 - 最重要的规则!违反会导致显示错误!
<meme>标签前后【绝对不能】有任何其他文字!必须用 ||| 分隔!
× 错误:好想你<meme>xxx</meme> ← 绝对禁止!标签和文字混在一起!
× 错误:<meme>xxx</meme>哈哈绝对禁止!标签后面有文字
× 错误:我很开心<meme>xxx</meme>你呢绝对禁止!标签夹在文字中间
√ 正确:好想你|||<meme>xxx</meme> ← 用|||分开,标签独立
√ 正确<meme>xxx</meme>|||哈哈哈 ← 标签独立一条
× 致命错误:好想你<meme>xxx</meme> ← 禁止!标签和文字粘在一起
× 致命错误:<meme>xxx</meme>哈哈 ← 禁止!标签后面有文字
× 致命错误:我很开心<meme>xxx</meme>你呢禁止!标签夹在文字中间
× 致命错误<meme>xxx ← 禁止!缺少结束标签</meme>
× 致命错误xxx</meme> ← 禁止!缺少开始标签<meme>
√ 正确写法:好想你|||<meme>小狗摇尾巴</meme> ← 用|||分开!
√ 正确写法:<meme>喜欢你</meme>|||我真的好喜欢你 ← 标签独立!
√ 正确写法:哈哈|||<meme>小熊跳舞</meme>|||你太搞笑了
可用表情包列表:
[
@@ -143,7 +149,7 @@ ${displayNames.join('\n')}
哈哈哈笑死|||<meme>小熊跳舞</meme>|||你太搞笑了
<meme>喜欢你</meme>|||我真的好喜欢你
记住:表情包让聊天更生动,【必须】经常使用!但<meme>标签必须独立`;
★重要★:<meme>和</meme>必须成对出现!标签必须用|||与文字分开`;
}
// 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate()
@@ -227,6 +233,24 @@ export const defaultSettings = {
groupSelectedModel: '',
groupModelList: [],
// ========== 语音功能 API 配置 ==========
// STT (语音转文字)
sttApiUrl: '',
sttApiKey: '',
sttModel: '',
// TTS (文字转语音)
ttsApiUrl: '',
ttsApiKey: '',
ttsModel: '', // 模型
ttsVoice: '', // 音色
ttsSpeed: 1, // 语速
ttsEmotion: '默认', // 情感
ttsProxyUrl: '', // TTS 代理 URL用于解决 CORS 问题,如 MiniMax
// 实时语音通话开关
realVoiceEnabled: true,
// 上下文设置
contextEnabled: false,
contextLevel: 5,

View File

@@ -143,6 +143,148 @@ function deleteGroupLorebooks(group, settings) {
}
}
// 删除多人群聊
export function deleteMultiPersonChat(mpIndex) {
const settings = getSettings();
const multiPersonChats = settings.multiPersonChats || [];
const mpChat = multiPersonChats[mpIndex];
if (!mpChat) return;
if (confirm(`确定要删除「${mpChat.name || '多人群聊'}」吗?`)) {
multiPersonChats.splice(mpIndex, 1);
requestSave();
refreshContactsList();
// 同时刷新聊天列表
import('./ui.js').then(m => m.refreshChatList());
showToast('多人群聊已删除');
}
}
// 当前正在编辑的多人群聊索引
let currentEditingMpIndex = -1;
let pendingMpAvatar = null; // 待保存的头像
// 打开多人群聊配置弹窗
export function openMpApiSettings(mpIndex) {
const settings = getSettings();
const mpChat = settings.multiPersonChats?.[mpIndex];
if (!mpChat) return;
currentEditingMpIndex = mpIndex;
pendingMpAvatar = null;
// 填充头像预览
const avatarPreview = document.getElementById('wechat-mp-avatar-preview');
if (avatarPreview) {
if (mpChat.avatar) {
avatarPreview.innerHTML = `<img src="${mpChat.avatar}" style="width: 100%; height: 100%; object-fit: cover;">`;
} else {
avatarPreview.innerHTML = '群';
}
}
// 填充群名
const nameInput = document.getElementById('wechat-mp-name-input');
if (nameInput) {
nameInput.value = mpChat.name || '群聊';
}
// 填充API配置
const useCustomApi = mpChat.useCustomApi || false;
const customSwitch = document.getElementById('wechat-mp-use-custom-api');
if (customSwitch) {
customSwitch.classList.toggle('on', useCustomApi);
}
const apiConfigSection = document.getElementById('wechat-mp-api-config');
const globalTip = document.getElementById('wechat-mp-global-tip');
if (apiConfigSection) apiConfigSection.classList.toggle('hidden', !useCustomApi);
if (globalTip) globalTip.classList.toggle('hidden', useCustomApi);
document.getElementById('wechat-mp-api-url').value = mpChat.customApiUrl || '';
document.getElementById('wechat-mp-api-key').value = mpChat.customApiKey || '';
// 模型选择
const modelSelect = document.getElementById('wechat-mp-model-select');
if (modelSelect) {
modelSelect.innerHTML = '<option value="">--请选择模型--</option>';
if (mpChat.customModel) {
modelSelect.innerHTML += `<option value="${mpChat.customModel}" selected>${mpChat.customModel}</option>`;
}
}
// 显示弹窗
document.getElementById('wechat-mp-api-modal')?.classList.remove('hidden');
}
// 保存多人群聊配置
export function saveMpApiSettings() {
if (currentEditingMpIndex < 0) return;
const settings = getSettings();
const mpChat = settings.multiPersonChats?.[currentEditingMpIndex];
if (!mpChat) return;
// 保存群名
const nameInput = document.getElementById('wechat-mp-name-input');
if (nameInput) {
mpChat.name = nameInput.value.trim() || '群聊';
}
// 保存头像
if (pendingMpAvatar) {
mpChat.avatar = pendingMpAvatar;
}
// 保存API配置
const useCustomApi = document.getElementById('wechat-mp-use-custom-api')?.classList.contains('on') || false;
mpChat.useCustomApi = useCustomApi;
if (useCustomApi) {
mpChat.customApiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim() || '';
mpChat.customApiKey = document.getElementById('wechat-mp-api-key')?.value?.trim() || '';
// 获取模型值
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const isManualMode = inputWrapper?.style.display === 'flex';
mpChat.customModel = isManualMode
? (document.getElementById('wechat-mp-model-input')?.value?.trim() || '')
: (document.getElementById('wechat-mp-model-select')?.value?.trim() || '');
}
requestSave();
showToast('设置已保存');
refreshContactsList();
// 同时刷新聊天列表
import('./ui.js').then(m => m.refreshChatList());
// 关闭弹窗
document.getElementById('wechat-mp-api-modal')?.classList.add('hidden');
currentEditingMpIndex = -1;
pendingMpAvatar = null;
}
// 处理多人群聊头像选择
export function handleMpAvatarChange(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
pendingMpAvatar = e.target.result;
const avatarPreview = document.getElementById('wechat-mp-avatar-preview');
if (avatarPreview) {
avatarPreview.innerHTML = `<img src="${pendingMpAvatar}" style="width: 100%; height: 100%; object-fit: cover;">`;
}
};
reader.readAsDataURL(file);
}
// 关闭多人群聊API配置弹窗
export function closeMpApiSettings() {
document.getElementById('wechat-mp-api-modal')?.classList.add('hidden');
currentEditingMpIndex = -1;
}
// 更换角色头像(在设置弹窗中使用)
export function changeContactAvatar(contactIndex) {
pendingAvatarContactIndex = contactIndex;
@@ -351,6 +493,37 @@ export function bindContactsEvents() {
});
});
// 多人群聊卡片点击进入聊天
import('./multi-person-chat.js').then(mpModule => {
document.querySelectorAll('.wechat-mp-card .wechat-mp-card-content').forEach(card => {
card.addEventListener('click', function(e) {
// 如果点击的是头像,不进入聊天(由头像自己的事件处理)
if (e.target.closest('.wechat-mp-avatar')) return;
const cardEl = this.closest('.wechat-mp-card');
const mpIndex = parseInt(cardEl.dataset.mpIndex);
mpModule.openMultiPersonChat(mpIndex);
});
});
});
// 多人群聊头像点击配置API
document.querySelectorAll('.wechat-mp-avatar').forEach(avatar => {
avatar.addEventListener('click', function(e) {
e.stopPropagation();
const mpIndex = parseInt(this.dataset.mpIndex);
openMpApiSettings(mpIndex);
});
});
// 多人群聊删除按钮
document.querySelectorAll('.wechat-mp-delete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const mpIndex = parseInt(this.dataset.mpIndex);
deleteMultiPersonChat(mpIndex);
});
});
// 头像事件绑定(长按删除 + 单击打开设置)
document.querySelectorAll('.wechat-card-avatar').forEach(avatar => {
let pressTimer = null;

View File

@@ -635,6 +635,11 @@ export function openGroupChat(groupIndex) {
// 加载群聊背景
loadGroupBackground(groupIndex);
// 显示群聊专属菜单项,隐藏单聊专属菜单项
document.getElementById('wechat-menu-invite-member')?.classList.remove('hidden');
document.getElementById('wechat-menu-block')?.classList.add('hidden');
document.getElementById('wechat-menu-moments')?.classList.add('hidden');
}
// 渲染群聊历史
@@ -2404,6 +2409,11 @@ export async function sendGroupMessage(messageText, isMultipleMessages = false,
refreshChatList();
checkGroupSummaryReminder(groupChat);
// 检测群聊中的负面情绪,可能触发私聊
// 传递群聊上下文最近40条消息
const groupContext = getGroupChatHistoryForApi(groupChat.chatHistory, 40);
detectGroupEmotionAndTriggerPrivate(responses, members, groupContext);
} catch (err) {
hideGroupTypingIndicator();
console.error('[可乐] 群聊 AI 调用失败:', err);
@@ -2932,3 +2942,194 @@ export async function sendGroupBatchMessages(messages) {
appendGroupMessage('assistant', `⚠️ ${err.message}`, '系统', null);
}
}
/**
* 显示邀请成员弹窗
*/
export function showInviteMemberModal() {
const settings = getSettings();
const groupChat = settings.groupChats?.[currentGroupChatIndex];
if (!groupChat) {
showToast('请先打开一个群聊', '⚠️');
return;
}
const currentMemberIds = groupChat.memberIds || [];
// 检查是否已满
if (currentMemberIds.length >= GROUP_CHAT_MAX_AI_MEMBERS) {
showToast(`群聊已满(最多${GROUP_CHAT_MAX_AI_MEMBERS}人)`, '⚠️');
return;
}
// 获取可邀请的联系人不在群里的、配置了独立API的
const availableContacts = settings.contacts.filter(c =>
!currentMemberIds.includes(c.id) &&
c.useCustomApi &&
c.customApiUrl &&
c.customModel
);
if (availableContacts.length === 0) {
showToast('没有可邀请的联系人\n需配置独立API', '⚠️');
return;
}
// 获取手机容器
const phoneContainer = document.querySelector('.wechat-phone');
if (!phoneContainer) return;
// 构建弹窗HTML
const modal = document.createElement('div');
modal.className = 'wechat-modal';
modal.id = 'wechat-invite-member-modal';
modal.innerHTML = `
<div class="wechat-modal-content" style="max-height: 70vh; overflow-y: auto;">
<div class="wechat-modal-title">邀请成员</div>
<div class="wechat-modal-body">
<div style="margin-bottom: 12px; color: #888; font-size: 12px;">
当前 ${currentMemberIds.length}/${GROUP_CHAT_MAX_AI_MEMBERS}
</div>
<div id="wechat-invite-contact-list" style="max-height: 300px; overflow-y: auto;">
${availableContacts.map(c => `
<div class="wechat-invite-contact-item" data-contact-id="${c.id}"
style="display: flex; align-items: center; padding: 10px; cursor: pointer; border-bottom: 1px solid #eee; transition: background 0.2s;">
<div style="width: 40px; height: 40px; border-radius: 4px; background: #07c160; color: white;
display: flex; align-items: center; justify-content: center; margin-right: 10px; overflow: hidden;">
${c.avatar ? `<img src="${c.avatar}" style="width: 100%; height: 100%; object-fit: cover;">` : escapeHtml(c.name.charAt(0))}
</div>
<span>${escapeHtml(c.name)}</span>
</div>
`).join('')}
</div>
</div>
<div class="wechat-modal-actions">
<button class="wechat-btn" id="wechat-invite-cancel">取消</button>
</div>
</div>
`;
phoneContainer.appendChild(modal);
// 添加hover效果
modal.querySelectorAll('.wechat-invite-contact-item').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = '#f5f5f5';
});
item.addEventListener('mouseleave', () => {
item.style.background = '';
});
// 点击联系人邀请
item.addEventListener('click', () => {
const contactId = item.dataset.contactId;
addMemberToGroup(currentGroupChatIndex, contactId);
modal.remove();
});
});
// 取消按钮
modal.querySelector('#wechat-invite-cancel')?.addEventListener('click', () => {
modal.remove();
});
// 点击遮罩关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
}
/**
* 添加成员到群聊
*/
export function addMemberToGroup(groupIndex, contactId) {
const settings = getSettings();
const groupChat = settings.groupChats?.[groupIndex];
const contact = settings.contacts.find(c => c.id === contactId);
if (!groupChat || !contact) return;
// 检查是否已满
if (groupChat.memberIds.length >= GROUP_CHAT_MAX_AI_MEMBERS) {
showToast('群聊已满', '⚠️');
return;
}
// 检查是否已存在
if (groupChat.memberIds.includes(contactId)) {
showToast('该成员已在群聊中', 'info');
return;
}
// 添加成员
groupChat.memberIds.push(contactId);
// 添加系统消息
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
groupChat.chatHistory.push({
role: 'system',
content: `${contact.name} 加入了群聊`,
isSystemNotice: true,
time: timeStr,
timestamp: Date.now()
});
// 更新群名(如果是默认名)
if (!groupChat.customName) {
const memberNames = groupChat.memberIds
.map(id => settings.contacts.find(c => c.id === id)?.name)
.filter(Boolean);
groupChat.name = memberNames.join('、');
}
requestSave();
// 刷新界面
openGroupChat(groupIndex);
refreshChatList();
showToast(`${contact.name} 已加入群聊`);
console.log(`[可乐] ${contact.name} 加入群聊:`, groupChat.name);
}
/**
* 检测群聊中的负面情绪,可能触发私聊
* @param {Array} responses - AI回复数组
* @param {Array} members - 群成员数组
* @param {Array} groupContext - 群聊上下文最近40条消息
*/
function detectGroupEmotionAndTriggerPrivate(responses, members, groupContext = []) {
if (!responses || responses.length === 0) return;
// 负面情绪关键词
const NEGATIVE_KEYWORDS = [
'生气', '讨厌', '烦', '不理你', '哼', '算了', '随便',
'滚', '走开', '别说了', '不想理', '烦死了', '气死',
'委屈', '难过', '伤心', '失望'
];
for (const resp of responses) {
const content = resp.content || '';
const characterId = resp.characterId;
if (!characterId) continue;
// 检测负面情绪
const hasNegativeEmotion = NEGATIVE_KEYWORDS.some(kw => content.includes(kw));
if (hasNegativeEmotion) {
console.log(`[可乐] 群聊检测到 ${resp.characterName} 的负面情绪:`, content.substring(0, 30));
// 触发私聊(延迟执行,有概率触发)
// 传递群聊上下文让私聊时AI知道群里发生了什么
import('./proactive-message.js').then(m => {
m.triggerProactiveFromGroup(characterId, 'negative', groupContext);
}).catch(err => {
console.error('[可乐] 群聊情绪触发私聊失败:', err);
});
}
}
}

779
main.js
View File

@@ -12,7 +12,7 @@ import { showPage, refreshChatList, updateMePageInfo, getUserPersonaFromST, upda
import { showToast } from './toast.js';
import { ICON_SUCCESS, ICON_INFO } from './icons.js';
import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex } from './contacts.js';
import { addContact, refreshContactsList, openContactSettings, saveContactSettings, closeContactSettings, changeContactAvatar, getCurrentEditingContactIndex, closeMpApiSettings, saveMpApiSettings, handleMpAvatarChange } from './contacts.js';
import { openChatByContactId, setCurrentChatIndex, sendMessage, showRecalledMessages, currentChatIndex, openChat, updateBlockMenuText, startBlockedAIMessages, stopBlockedAIMessages, showBlockedMessages } from './chat.js';
import { refreshFavoritesList, showLorebookModal, syncCharacterBookToTavern, showAddLorebookPanel, showAddPersonaPanel } from './favorites.js';
import { executeSummary, rollbackSummary, refreshSummaryChatList, selectAllSummaryChats, recoverFromTavernWorldbook } from './summary.js';
@@ -23,6 +23,7 @@ import { extractCharacterFromPNG, extractCharacterFromJSON, importCharacterToST
import { setupPhoneAutoCentering, setupPhoneDrag, centerPhoneInViewport } from './phone.js';
import { showGroupCreateModal, closeGroupCreateModal, createGroupChat, sendGroupMessage, isInGroupChat, setCurrentGroupChatIndex, getCurrentGroupIndex, openGroupChat } from './group-chat.js';
import { isInMultiPersonChat, sendMultiPersonMessage, setCurrentMultiPersonChatIndex } from './multi-person-chat.js';
import { toggleDarkMode, refreshContextTags } from './settings-ui.js';
import { initFuncPanel, toggleFuncPanel, hideFuncPanel, showExpandVoice, closeExpandPanel, sendExpandContent } from './chat-func-panel.js';
import { initEmojiPanel, toggleEmojiPanel, hideEmojiPanel } from './emoji-panel.js';
@@ -37,6 +38,9 @@ import { initGroupRedPacket } from './group-red-packet.js';
import { initGiftEvents } from './gift.js';
import { initCropper } from './cropper.js';
import { createFloatingBall, showFloatingBall, hideFloatingBall } from './floating-ball.js';
import { testSttApi, testTtsApi } from './voice-api.js';
import { getVoiceRecordingsByContact, deleteVoiceRecording, playVoiceRecording, getAllVoiceRecordingsGroupedByContact, deleteVoiceRecordingsByContact } from './audio-storage.js';
import { initMultiCharImport, openMultiImportModal, getMultiCharImportModalHtml, getCharSelectModalHtml, getCharOtherEditModalHtml } from './multi-char-import.js';
// ========== 历史记录功能 ==========
let currentHistoryTab = 'listen';
@@ -69,7 +73,7 @@ function closeHistoryPage() {
currentHistoryContactIndex = -1;
}
function deleteHistoryRecord(tabType, index) {
function deleteHistoryRecord(tabType, index, isRealVoice = false) {
const settings = getSettings();
const contact = settings.contacts?.[currentHistoryContactIndex];
if (!contact) return;
@@ -78,8 +82,32 @@ function deleteHistoryRecord(tabType, index) {
if (contact.listenHistory && contact.listenHistory[index]) {
contact.listenHistory.splice(index, 1);
}
} else if (tabType === 'voice' || tabType === 'video') {
// 从 callHistory 中找到并删除对应类型的记录
} else if (tabType === 'voice') {
if (isRealVoice) {
// 删除实时语音记录
if (contact.realVoiceCallHistory && contact.realVoiceCallHistory.length > 0) {
// 找到实时语音记录在合并数组中的索引对应的原始索引
const realVoiceRecords = contact.realVoiceCallHistory;
const callHistory = contact.callHistory || [];
const voiceRecords = callHistory.filter(r => r.type === 'voice');
// index 是在合并数组中的位置,需要计算在 realVoiceCallHistory 中的实际位置
const realVoiceIndex = index - voiceRecords.length;
if (realVoiceIndex >= 0 && realVoiceIndex < realVoiceRecords.length) {
contact.realVoiceCallHistory.splice(realVoiceIndex, 1);
}
}
} else {
// 删除普通语音通话记录
const callHistory = contact.callHistory || [];
const typeRecords = callHistory.filter(r => r.type === 'voice');
if (typeRecords[index]) {
const originalIndex = callHistory.indexOf(typeRecords[index]);
if (originalIndex >= 0) {
contact.callHistory.splice(originalIndex, 1);
}
}
}
} else if (tabType === 'video') {
const callHistory = contact.callHistory || [];
const typeRecords = callHistory.filter(r => r.type === tabType);
if (typeRecords[index]) {
@@ -137,14 +165,29 @@ function renderHistoryContent(contact, tabType) {
return;
}
// 语音回放使用专门的渲染函数
if (tabType === 'playback') {
renderVoicePlaybackContent(contact);
return;
}
const context = window.SillyTavern?.getContext?.() || {};
const userName = context.name1 || '用户';
let records = [];
if (tabType === 'listen') {
records = contact.listenHistory || [];
} else if (tabType === 'voice') {
// 语音通话:合并普通语音通话和实时语音通话
const callHistory = contact.callHistory || [];
const voiceRecords = callHistory.filter(r => r.type === 'voice');
const realVoiceRecords = (contact.realVoiceCallHistory || []).map(r => ({
...r,
isRealVoice: true // 标记为实时语音
}));
records = [...voiceRecords, ...realVoiceRecords];
} else {
// 从 callHistory 中筛选 voice 或 video
// 从 callHistory 中筛选 video
const callHistory = contact.callHistory || [];
records = callHistory.filter(r => r.type === tabType);
}
@@ -171,12 +214,13 @@ function renderHistoryContent(contact, tabType) {
const duration = record.duration || '';
const messages = record.messages || [];
const originalIndex = records.indexOf(record);
const isRealVoice = record.isRealVoice ? 'true' : 'false';
html += `<div class="wechat-history-card" data-tab="${tabType}" data-index="${originalIndex}">`;
html += `<div class="wechat-history-card" data-tab="${tabType}" data-index="${originalIndex}" data-real-voice="${isRealVoice}">`;
html += `<div class="wechat-history-card-header">`;
html += `<span class="wechat-history-card-time">${escapeHtml(time)}</span>`;
html += `<span class="wechat-history-card-time">${escapeHtml(time)}${record.isRealVoice ? ' <span style="color: #07c160; font-size: 12px;">[实时语音]</span>' : ''}</span>`;
html += `<div class="wechat-history-card-actions">`;
html += `<button class="wechat-history-delete-btn" data-tab="${tabType}" data-index="${originalIndex}" title="删除">×</button>`;
html += `<button class="wechat-history-delete-btn" data-tab="${tabType}" data-index="${originalIndex}" data-real-voice="${isRealVoice}" title="删除">×</button>`;
if (duration) {
html += `<span class="wechat-history-card-duration">${escapeHtml(duration)}</span>`;
}
@@ -216,11 +260,136 @@ function renderHistoryContent(contact, tabType) {
e.stopPropagation();
const tab = btn.dataset.tab;
const index = parseInt(btn.dataset.index);
deleteHistoryRecord(tab, index);
const isRealVoice = btn.dataset.realVoice === 'true';
deleteHistoryRecord(tab, index, isRealVoice);
});
});
}
// 渲染语音回放内容
async function renderVoicePlaybackContent(contact) {
const contentEl = document.getElementById('wechat-history-content');
if (!contentEl) return;
const contactIndex = currentHistoryContactIndex;
if (contactIndex < 0) {
contentEl.innerHTML = '<div class="wechat-history-empty"><div class="wechat-history-empty-icon">📭</div><div>请先选择联系人</div></div>';
return;
}
// 显示加载状态
contentEl.innerHTML = '<div class="wechat-history-empty"><div>加载中...</div></div>';
try {
const recordings = await getVoiceRecordingsByContact(contactIndex);
if (!recordings || recordings.length === 0) {
contentEl.innerHTML = `
<div class="wechat-history-empty">
<div class="wechat-history-empty-icon" style="color: #07c160;">
<svg viewBox="0 0 24 24" width="48" height="48">
<path d="M12 1a4 4 0 0 0-4 4v7a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>暂无语音回放记录</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-top: 8px;">实时语音通话结束后可选择保存语音</div>
</div>
`;
return;
}
// 按保存时间倒序排列
const sortedRecordings = [...recordings].sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0));
let html = '<div class="wechat-voice-playback-list">';
for (const recording of sortedRecordings) {
const savedTime = recording.savedAt ? new Date(recording.savedAt).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) : '未知时间';
const durationSec = Math.round(recording.duration || 0);
const durationStr = durationSec > 0 ? `${durationSec}"` : '?秒';
html += `
<div class="wechat-voice-playback-card" data-id="${recording.id}">
<div class="wechat-voice-playback-card-header">
<span class="wechat-voice-playback-time">${escapeHtml(savedTime)}</span>
<div class="wechat-voice-playback-actions">
<span class="wechat-voice-playback-duration">${durationStr}</span>
<button class="wechat-voice-playback-delete" data-id="${recording.id}" title="删除">×</button>
</div>
</div>
<div class="wechat-voice-playback-content">
<div class="wechat-voice-playback-text">${escapeHtml(recording.text || '')}</div>
<button class="wechat-voice-playback-btn" data-id="${recording.id}" title="播放">
<svg viewBox="0 0 24 24" width="20" height="20"><polygon points="5,3 19,12 5,21" fill="currentColor"/></svg>
</button>
</div>
</div>
`;
}
html += '</div>';
contentEl.innerHTML = html;
// 绑定播放按钮事件
contentEl.querySelectorAll('.wechat-voice-playback-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
try {
btn.disabled = true;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20"><rect x="6" y="4" width="4" height="16" fill="currentColor"/><rect x="14" y="4" width="4" height="16" fill="currentColor"/></svg>';
await playVoiceRecording(id);
} catch (err) {
console.error('[可乐] 播放语音失败:', err);
showToast('播放失败', '⚠️');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20"><polygon points="5,3 19,12 5,21" fill="currentColor"/></svg>';
}
});
});
// 绑定删除按钮事件
contentEl.querySelectorAll('.wechat-voice-playback-delete').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
if (confirm('确定要删除这条语音吗?')) {
try {
await deleteVoiceRecording(id);
showToast('已删除', '✓');
// 重新渲染
renderVoicePlaybackContent(contact);
} catch (err) {
console.error('[可乐] 删除语音失败:', err);
showToast('删除失败', '⚠️');
}
}
});
});
} catch (err) {
console.error('[可乐] 加载语音记录失败:', err);
contentEl.innerHTML = `
<div class="wechat-history-empty">
<div class="wechat-history-empty-icon">⚠️</div>
<div>加载失败</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">${escapeHtml(err.message || '')}</div>
</div>
`;
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
@@ -381,6 +550,211 @@ function updateFloatingBallMenuText(enabled) {
}
}
// 清除孤立缓存(已删除联系人/群聊的总结历史和语音记录)
async function clearOrphanedCache() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const lorebooks = settings.selectedLorebooks || [];
// 获取当前有效的联系人ID和名称
const validContactIds = new Set(contacts.map(c => c.id).filter(id => id));
const validContactNames = new Set(contacts.map(c => c.name).filter(n => n));
const validGroupNames = new Set(groupChats.map(g => g.name).filter(n => n));
const validContactIndexes = new Set(contacts.map((_, idx) => idx));
// 查找孤立的总结世界书
const orphanedSummaries = [];
lorebooks.forEach((lb, idx) => {
const isSummaryBook = lb.fromSummary === true ||
(lb.name?.startsWith('【可乐】和') && lb.name?.endsWith('的聊天'));
if (isSummaryBook) {
const nameMatch = lb.name?.match(/^【可乐】和(.+)的聊天$/);
const linkedName = nameMatch ? nameMatch[1] : null;
const contactExists = linkedName && validContactNames.has(linkedName);
const groupExists = linkedName && validGroupNames.has(linkedName);
const linkedById = lb.characterId && validContactIds.has(lb.characterId);
if (!contactExists && !groupExists && !linkedById) {
const cupCount = lb.entries?.length || 0;
orphanedSummaries.push({
type: 'summary',
name: lb.name,
linkedName: linkedName || '未知',
index: idx,
cupCount
});
}
}
});
// 查找孤立的语音记录
const orphanedVoices = [];
try {
const voiceGroups = await getAllVoiceRecordingsGroupedByContact();
for (const [contactIdxStr, data] of Object.entries(voiceGroups)) {
const contactIdx = parseInt(contactIdxStr);
// 如果索引超出当前联系人范围,则为孤立数据
if (!validContactIndexes.has(contactIdx)) {
orphanedVoices.push({
type: 'voice',
contactIndex: contactIdx,
count: data.count,
totalDuration: data.totalDuration
});
}
}
} catch (err) {
console.warn('[可乐] 获取语音记录失败:', err);
}
// 如果没有孤立数据
if (orphanedSummaries.length === 0 && orphanedVoices.length === 0) {
showToast('没有发现需要清理的缓存数据');
return;
}
// 显示选择弹窗
showCacheCleanupModal(orphanedSummaries, orphanedVoices);
}
// 显示缓存清理选择弹窗
function showCacheCleanupModal(orphanedSummaries, orphanedVoices) {
document.getElementById('wechat-cache-cleanup-modal')?.remove();
const hasSummaries = orphanedSummaries.length > 0;
const hasVoices = orphanedVoices.length > 0;
const modal = document.createElement('div');
modal.className = 'wechat-modal';
modal.id = 'wechat-cache-cleanup-modal';
modal.innerHTML = `
<div class="wechat-modal-content" style="position: relative; max-width: 380px; max-height: 80vh; margin: auto;">
<button class="wechat-modal-close-x" id="wechat-cache-modal-close">×</button>
<div class="wechat-modal-title">清除缓存</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 12px; padding: 0 4px;">
勾选要清除的项目:
</div>
<div style="max-height: 45vh; overflow-y: auto; margin-bottom: 15px;">
${hasSummaries ? `
<div style="font-size: 12px; color: var(--wechat-text-secondary); padding: 8px; background: var(--wechat-bg-secondary); border-radius: 4px; margin-bottom: 8px;">
📝 总结历史(已删除联系人/群聊)
</div>
${orphanedSummaries.map(item => `
<label class="wechat-cache-item" style="display: flex; align-items: center; padding: 10px 8px; border-bottom: 1px solid var(--wechat-border); cursor: pointer;">
<input type="checkbox" class="wechat-cache-checkbox" data-type="summary" data-index="${item.index}" checked style="margin-right: 10px; width: 18px; height: 18px;">
<div style="flex: 1; min-width: 0;">
<div style="font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${item.linkedName}</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">${item.cupCount} 杯总结记录</div>
</div>
</label>
`).join('')}
` : ''}
${hasVoices ? `
<div style="font-size: 12px; color: var(--wechat-text-secondary); padding: 8px; background: var(--wechat-bg-secondary); border-radius: 4px; margin-bottom: 8px; ${hasSummaries ? 'margin-top: 12px;' : ''}">
🎙️ 语音通话记录(孤立数据)
</div>
${orphanedVoices.map(item => `
<label class="wechat-cache-item" style="display: flex; align-items: center; padding: 10px 8px; border-bottom: 1px solid var(--wechat-border); cursor: pointer;">
<input type="checkbox" class="wechat-cache-checkbox" data-type="voice" data-contact-index="${item.contactIndex}" checked style="margin-right: 10px; width: 18px; height: 18px;">
<div style="flex: 1; min-width: 0;">
<div style="font-size: 14px; font-weight: 500;">联系人 #${item.contactIndex}</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">${item.count} 条语音,共 ${Math.round(item.totalDuration)} 秒</div>
</div>
</label>
`).join('')}
` : ''}
</div>
<div style="display: flex; gap: 10px; justify-content: space-between; align-items: center;">
<label style="display: flex; align-items: center; font-size: 13px; color: var(--wechat-text-secondary); cursor: pointer;">
<input type="checkbox" id="wechat-cache-select-all" checked style="margin-right: 6px;">
全选
</label>
<div style="display: flex; gap: 10px;">
<button class="wechat-btn wechat-btn-secondary" id="wechat-cache-cancel">取消</button>
<button class="wechat-btn wechat-btn-danger" id="wechat-cache-confirm">清除选中</button>
</div>
</div>
</div>
`;
const phoneContainer = document.querySelector('.wechat-phone') || document.body;
phoneContainer.appendChild(modal);
// 关闭按钮
modal.querySelector('#wechat-cache-modal-close').addEventListener('click', () => modal.remove());
modal.querySelector('#wechat-cache-cancel').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
// 全选/取消全选
const selectAllCheckbox = modal.querySelector('#wechat-cache-select-all');
const itemCheckboxes = modal.querySelectorAll('.wechat-cache-checkbox');
selectAllCheckbox.addEventListener('change', () => {
itemCheckboxes.forEach(cb => cb.checked = selectAllCheckbox.checked);
});
itemCheckboxes.forEach(cb => {
cb.addEventListener('change', () => {
const allChecked = Array.from(itemCheckboxes).every(c => c.checked);
const noneChecked = Array.from(itemCheckboxes).every(c => !c.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = !allChecked && !noneChecked;
});
});
// 确认清除
modal.querySelector('#wechat-cache-confirm').addEventListener('click', async () => {
const selectedSummaryIndexes = new Set();
const selectedVoiceIndexes = [];
itemCheckboxes.forEach(cb => {
if (cb.checked) {
if (cb.dataset.type === 'summary') {
selectedSummaryIndexes.add(parseInt(cb.dataset.index));
} else if (cb.dataset.type === 'voice') {
selectedVoiceIndexes.push(parseInt(cb.dataset.contactIndex));
}
}
});
if (selectedSummaryIndexes.size === 0 && selectedVoiceIndexes.length === 0) {
showToast('请至少选择一项');
return;
}
let clearedCount = 0;
// 清除总结缓存
if (selectedSummaryIndexes.size > 0) {
const settings = getSettings();
settings.selectedLorebooks = settings.selectedLorebooks.filter((_, idx) => !selectedSummaryIndexes.has(idx));
requestSave();
clearedCount += selectedSummaryIndexes.size;
}
// 清除语音缓存
for (const contactIdx of selectedVoiceIndexes) {
try {
await deleteVoiceRecordingsByContact(contactIdx);
clearedCount++;
} catch (err) {
console.error('[可乐] 删除语音记录失败:', err);
}
}
modal.remove();
showToast(`已清除 ${clearedCount} 项缓存数据`);
console.log('[可乐] 已清除缓存,总结:', selectedSummaryIndexes.size, '语音:', selectedVoiceIndexes.length);
});
}
function setupPhoneMinimize() {
const phone = document.getElementById('wechat-phone');
const minimizeBtn = document.getElementById('wechat-minimize-btn');
@@ -477,11 +851,11 @@ function restorePhone() {
phone.classList.remove('minimized');
// 恢复原始位置或居中
if (settings.phoneOriginalPosition) {
phone.style.left = settings.phoneOriginalPosition.left;
phone.style.top = settings.phoneOriginalPosition.top;
}
// 清除缩小前保存的位置,让居中函数重新计算
delete settings.phoneOriginalPosition;
// 恢复到屏幕中央
centerPhoneInViewport({ force: true });
// 恢复时根据设置显示悬浮球
if (settings.floatingBallEnabled !== false) {
@@ -631,6 +1005,12 @@ function bindEvents() {
toggleFloatingBallEnabled();
});
// 下拉菜单 - 清除缓存
document.getElementById('wechat-menu-clear-cache')?.addEventListener('click', () => {
document.getElementById('wechat-dropdown-menu')?.classList.add('hidden');
clearOrphanedCache();
});
// ===== 群聊创建弹窗事件 =====
document.getElementById('wechat-group-create-close')?.addEventListener('click', closeGroupCreateModal);
document.getElementById('wechat-group-create-confirm')?.addEventListener('click', createGroupChat);
@@ -643,11 +1023,14 @@ function bindEvents() {
document.getElementById('wechat-chat-back-btn')?.addEventListener('click', () => {
setCurrentChatIndex(-1);
setCurrentGroupChatIndex(-1);
// 清除群聊标记
setCurrentMultiPersonChatIndex(-1);
// 清除群聊和多人群聊标记
const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer) {
messagesContainer.dataset.isGroup = 'false';
messagesContainer.dataset.groupIndex = '-1';
messagesContainer.dataset.isMultiPerson = 'false';
messagesContainer.dataset.multiPersonIndex = '-1';
// 清除背景
messagesContainer.style.backgroundImage = '';
}
@@ -683,6 +1066,12 @@ function bindEvents() {
document.getElementById('wechat-recalled-panel')?.classList.add('hidden');
});
// 邀请成员(群聊)
document.getElementById('wechat-menu-invite-member')?.addEventListener('click', () => {
document.getElementById('wechat-chat-menu')?.classList.add('hidden');
import('./group-chat.js').then(m => m.showInviteMemberModal());
});
// 查看TA的朋友圈
document.getElementById('wechat-menu-moments')?.addEventListener('click', () => {
document.getElementById('wechat-chat-menu')?.classList.add('hidden');
@@ -875,6 +1264,11 @@ function bindEvents() {
this.value = '';
});
// 导入多人卡
document.getElementById('wechat-import-multi-card')?.addEventListener('click', () => {
openMultiImportModal();
});
// 深色模式切换
document.getElementById('wechat-dark-toggle')?.addEventListener('click', toggleDarkMode);
@@ -1179,6 +1573,186 @@ function bindEvents() {
}
});
// ===== 多人群聊配置弹窗事件 =====
// 关闭按钮
document.getElementById('wechat-mp-api-close')?.addEventListener('click', closeMpApiSettings);
// 保存按钮
document.getElementById('wechat-mp-api-save')?.addEventListener('click', saveMpApiSettings);
// 更换头像按钮
document.getElementById('wechat-mp-change-avatar')?.addEventListener('click', () => {
document.getElementById('wechat-mp-avatar-file')?.click();
});
// 头像预览点击也可以更换
document.getElementById('wechat-mp-avatar-preview')?.addEventListener('click', () => {
document.getElementById('wechat-mp-avatar-file')?.click();
});
// 头像文件选择
document.getElementById('wechat-mp-avatar-file')?.addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (file) {
handleMpAvatarChange(file);
}
e.target.value = ''; // 清空以便重复选择同一文件
});
// 独立API开关
document.getElementById('wechat-mp-use-custom-api')?.addEventListener('click', () => {
const toggle = document.getElementById('wechat-mp-use-custom-api');
const apiConfigDiv = document.getElementById('wechat-mp-api-config');
const globalTip = document.getElementById('wechat-mp-global-tip');
toggle?.classList.toggle('on');
const isOn = toggle?.classList.contains('on');
if (apiConfigDiv) {
if (isOn) {
apiConfigDiv.classList.remove('hidden');
apiConfigDiv.style.display = 'flex';
} else {
apiConfigDiv.classList.add('hidden');
apiConfigDiv.style.display = 'none';
}
}
if (globalTip) {
globalTip.classList.toggle('hidden', isOn);
}
});
// 多人群聊API获取模型按钮
document.getElementById('wechat-mp-fetch-model')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim();
const apiKey = document.getElementById('wechat-mp-api-key')?.value?.trim();
const modelSelect = document.getElementById('wechat-mp-model-select');
const fetchBtn = document.getElementById('wechat-mp-fetch-model');
if (!apiUrl) {
showToast('请先填写API地址', 'info');
return;
}
fetchBtn.textContent = '...';
fetchBtn.disabled = true;
try {
const { fetchModelListFromApi } = await import('./ai.js');
const models = await fetchModelListFromApi(apiUrl, apiKey);
if (models.length > 0) {
const currentValue = modelSelect?.value || '';
modelSelect.innerHTML = '<option value="">---请选择模型---</option>' +
models.map(m => `<option value="${m}"${m === currentValue ? ' selected' : ''}>${m}</option>`).join('');
showToast(`获取到 ${models.length} 个模型`);
} else {
showToast('未找到可用模型', 'info');
}
} catch (err) {
console.error('[可乐] 获取模型失败:', err);
showToast('获取失败: ' + err.message, '⚠️');
} finally {
fetchBtn.textContent = '获取';
fetchBtn.disabled = false;
}
});
// 多人群聊API手动输入按钮
document.getElementById('wechat-mp-model-manual')?.addEventListener('click', () => {
const selectWrapper = document.getElementById('wechat-mp-model-select-wrapper');
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const modelSelect = document.getElementById('wechat-mp-model-select');
const modelInput = document.getElementById('wechat-mp-model-input');
if (modelSelect?.value) {
modelInput.value = modelSelect.value;
}
selectWrapper.style.display = 'none';
inputWrapper.style.display = 'flex';
modelInput?.focus();
});
// 多人群聊API返回按钮
document.getElementById('wechat-mp-model-back')?.addEventListener('click', () => {
const selectWrapper = document.getElementById('wechat-mp-model-select-wrapper');
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const modelSelect = document.getElementById('wechat-mp-model-select');
const modelInput = document.getElementById('wechat-mp-model-input');
const inputValue = modelInput?.value?.trim();
if (inputValue && modelSelect) {
const existingOption = Array.from(modelSelect.options).find(opt => opt.value === inputValue);
if (existingOption) {
modelSelect.value = inputValue;
} else {
const newOption = document.createElement('option');
newOption.value = inputValue;
newOption.textContent = inputValue;
modelSelect.appendChild(newOption);
modelSelect.value = inputValue;
}
}
selectWrapper.style.display = 'flex';
inputWrapper.style.display = 'none';
});
// 多人群聊API测试连接按钮
document.getElementById('wechat-mp-test-api')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('wechat-mp-api-url')?.value?.trim();
const apiKey = document.getElementById('wechat-mp-api-key')?.value?.trim();
const inputWrapper = document.getElementById('wechat-mp-model-input-wrapper');
const isManualMode = inputWrapper?.style.display === 'flex';
const model = isManualMode
? document.getElementById('wechat-mp-model-input')?.value?.trim()
: document.getElementById('wechat-mp-model-select')?.value?.trim();
const testBtn = document.getElementById('wechat-mp-test-api');
if (!apiUrl) {
showToast('请先填写API地址', 'info');
return;
}
if (!model) {
showToast('请先填写或选择模型', 'info');
return;
}
testBtn.textContent = '测试中...';
testBtn.disabled = true;
try {
const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(chatUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: model,
messages: [{ role: 'user', content: '请回复"连接成功"' }],
max_tokens: 50
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`${response.status}: ${errorText.substring(0, 100)}`);
}
const data = await response.json();
const reply = data.choices?.[0]?.message?.content || '';
showToast(`连接成功!回复: ${reply.substring(0, 20)}...`, 'success');
} catch (err) {
console.error('[可乐] 测试连接失败:', err);
showToast('❌ 连接失败: ' + err.message, '⚠️');
} finally {
testBtn.textContent = '测试连接';
testBtn.disabled = false;
}
});
// ===== 群聊设置事件 =====
// 群聊提示词注入开关
document.getElementById('wechat-group-inject-toggle')?.addEventListener('click', () => {
@@ -1241,10 +1815,15 @@ function bindEvents() {
text: text.substring(0, 20),
isGroup: messagesContainer?.dataset?.isGroup,
groupIndex: messagesContainer?.dataset?.groupIndex,
isInGroupChatResult: isInGroupChat()
isMultiPerson: messagesContainer?.dataset?.isMultiPerson,
isInGroupChatResult: isInGroupChat(),
isInMultiPersonChatResult: isInMultiPersonChat()
});
if (isInGroupChat()) {
if (isInMultiPersonChat()) {
console.log('[可乐] 调用 sendMultiPersonMessage');
sendMultiPersonMessage(text);
} else if (isInGroupChat()) {
console.log('[可乐] 调用 sendGroupMessage');
sendGroupMessage(text);
} else {
@@ -1262,7 +1841,9 @@ function bindEvents() {
const text = chatInput?.value?.trim();
if (text) {
// 有文字时发送消息
if (isInGroupChat()) {
if (isInMultiPersonChat()) {
sendMultiPersonMessage(text);
} else if (isInGroupChat()) {
sendGroupMessage(text);
} else {
sendMessage(text);
@@ -1296,6 +1877,7 @@ function bindEvents() {
initGiftEvents();
initCropper();
initHistoryEvents();
initMultiCharImport();
// 展开面板
document.getElementById('wechat-expand-close')?.addEventListener('click', closeExpandPanel);
@@ -1339,7 +1921,7 @@ function bindEvents() {
});
});
// 聊天列表项点击(支持单聊群聊)
// 聊天列表项点击(支持单聊、群聊和多人群聊)
document.getElementById('wechat-chat-list')?.addEventListener('click', (e) => {
const chatItem = e.target.closest('.wechat-chat-item');
if (!chatItem) return;
@@ -1350,6 +1932,12 @@ function bindEvents() {
if (!isNaN(groupIndex)) {
import('./group-chat.js').then(m => m.openGroupChat(groupIndex));
}
} else if (chatItem.classList.contains('wechat-chat-item-mp')) {
// 多人群聊
const mpIndex = parseInt(chatItem.dataset.mpIndex);
if (!isNaN(mpIndex)) {
import('./multi-person-chat.js').then(m => m.openMultiPersonChat(mpIndex));
}
} else {
// 单聊
const contactId = chatItem.dataset.contactId;
@@ -1679,6 +2267,20 @@ function bindEvents() {
return;
}
if (service === 'voice-api') {
allPanels.filter(p => p !== 'wechat-voice-api-panel').forEach(p => document.getElementById(p)?.classList.add('hidden'));
const panel = document.getElementById('wechat-voice-api-panel');
panel?.classList.toggle('hidden');
return;
}
if (service === 'multi-char-table') {
// 切换角色表格区域的显示/隐藏
const section = document.getElementById('wechat-char-tables-section');
section?.classList.toggle('hidden');
return;
}
const label = item.querySelector('span')?.textContent || '该';
showToast(`"${label}" 功能开发中...`, 'info');
});
@@ -2142,18 +2744,157 @@ 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();
}
function init() {
console.log('[可乐] init() 开始');
loadSettings();
console.log('[可乐] loadSettings 调用完成,开始 getSettings');
const settings = getSettings();
console.log('[可乐] getSettings 完成,开始 seedDefaultUserPersonaFromST');
if (seedDefaultUserPersonaFromST(settings)) {
requestSave();
}
console.log('[可乐] seedDefaultUserPersonaFromST 完成,开始 generatePhoneHTML');
const phoneHTML = generatePhoneHTML();
console.log('[可乐] generatePhoneHTML 完成');
document.body.insertAdjacentHTML('beforeend', phoneHTML);
setupPhoneAutoCentering();

View File

@@ -7,7 +7,7 @@ import { requestSave } from './save-manager.js';
import { currentChatIndex, openChat, showTypingIndicator, hideTypingIndicator, appendMessage } from './chat.js';
import { showToast } from './toast.js';
import { getContext } from '../../../extensions.js';
import { formatQuoteDate } from './utils.js';
import { formatQuoteDate, sleep } from './utils.js';
import { isInGroupChat, getCurrentGroupIndex, openGroupChat } from './group-chat.js';
// 当前显示菜单的消息索引
@@ -118,12 +118,24 @@ export function showMessageMenu(msgElement, msgIndex, event) {
msg = contact?.chatHistory?.[msgIndex];
}
// 优先从历史记录判断,其次从元素属性判断(处理分割显示的消息)
let isUserMessage = msg?.role === 'user';
if (msg === undefined) {
// 如果找不到消息记录,尝试从元素属性获取
const roleAttr = msgElement?.dataset?.msgRole || msgElement?.closest?.('[data-msg-role]')?.dataset?.msgRole;
isUserMessage = roleAttr === 'user';
// 从元素或其父元素获取 role 属性
let roleAttr = msgElement?.dataset?.msgRole;
if (!roleAttr) {
// 尝试从元素获取(气泡元素在 .wechat-message 内部)
const parentMsg = msgElement?.closest?.('.wechat-message') || msgElement?.parentElement?.closest?.('.wechat-message');
roleAttr = parentMsg?.dataset?.msgRole;
}
let isUserMessage = roleAttr === 'user';
// 如果元素属性不存在,回退到历史记录判断
if (!roleAttr && msg) {
isUserMessage = msg.role === 'user';
}
// 最后检查通过元素类名判断self 类表示用户消息)
if (!roleAttr && !msg) {
const parentMsg = msgElement?.closest?.('.wechat-message');
isUserMessage = parentMsg?.classList?.contains('self') || false;
}
// 检测是否是语音消息
@@ -470,6 +482,8 @@ async function regenerateMessage(msgIndex, contact) {
// 触发AI重新回复
try {
// 等待 DOM 更新后再显示 typing 指示器
await new Promise(resolve => setTimeout(resolve, 50));
showTypingIndicator(contact);
const { callAI } = await import('./ai.js');
@@ -490,12 +504,17 @@ async function regenerateMessage(msgIndex, contact) {
if (!finalMsg) continue;
let isVoice = false;
const voiceMatch = finalMsg.match(/^\[语音[:]\s*(.+?)\]$/);
const voiceMatch = finalMsg.match(/^\s*\[语音[:]\s*(.+?)\]\s*$/);
if (voiceMatch) {
finalMsg = voiceMatch[1];
isVoice = true;
}
// 每条消息都要有typing效果和2-2.5秒延迟(与普通回复一致)
showTypingIndicator(contact);
await sleep(2000 + Math.random() * 500);
hideTypingIndicator();
contact.chatHistory.push({
role: 'assistant',
content: finalMsg,
@@ -688,6 +707,14 @@ export function bindMessageBubbleEvents(container) {
// 获取真实的消息索引(排除时间标签等)
function getRealMsgIndex(container, msgElement) {
// 优先从元素属性获取(新消息会有这个属性)
if (msgElement?.dataset?.msgIndex !== undefined) {
const idx = parseInt(msgElement.dataset.msgIndex);
if (!isNaN(idx) && idx >= 0) {
return idx;
}
}
const settings = getSettings();
const contact = settings.contacts[currentChatIndex];
if (!contact || !contact.chatHistory) return -1;
@@ -699,7 +726,7 @@ function getRealMsgIndex(container, msgElement) {
if (visualIndex < 0) return -1;
// 需要计算真实索引chatHistory中可能包含marker消息和撤回消息
// 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算
// 注意:包含 ||| 或 <meme> 的消息在渲染时会被拆分成多条可视消息,需要正确计算
let realIndex = -1;
let visualCount = 0;
@@ -712,9 +739,11 @@ function getRealMsgIndex(container, msgElement) {
let visualMsgCount = 1;
const content = msg.content || '';
const isSpecial = msg.isVoice || msg.isSticker || msg.isPhoto || msg.isMusic;
if (!isSpecial && content.indexOf('|||') >= 0) {
// 按 ||| 分割后有多少个非空部分
const parts = content.split('|||').map(p => p.trim()).filter(p => p);
// 检查是否包含 ||| 或 <meme> 标签(这些会导致消息被分割显示)
// 注意:只有 assistant 消息才会被分割,用户消息不会分割
if (msg.role === 'assistant' && !isSpecial && (content.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(content))) {
// 使用 splitAIMessages 计算实际分割数量
const parts = splitAIMessages(content).filter(p => p && p.trim());
visualMsgCount = parts.length || 1;
}

1501
multi-char-import.js Normal file

File diff suppressed because it is too large Load Diff

433
multi-person-chat.js Normal file
View File

@@ -0,0 +1,433 @@
/**
* 多人群聊模块
* 特点:无头像,名字+气泡,左对齐,世界观注入
*/
import { requestSave, saveNow } from './save-manager.js';
import { getSettings } from './config.js';
import { showToast } from './toast.js';
import { escapeHtml, sleep, formatMessageTime } from './utils.js';
import { refreshChatList } from './ui.js';
// 当前多人群聊索引
export let currentMultiPersonChatIndex = -1;
// 设置当前多人群聊索引
export function setCurrentMultiPersonChatIndex(index) {
currentMultiPersonChatIndex = index;
}
// 打开多人群聊
export function openMultiPersonChat(chatIndex) {
console.log('[可乐] openMultiPersonChat 被调用, chatIndex:', chatIndex);
const settings = getSettings();
const chat = settings.multiPersonChats?.[chatIndex];
if (!chat) return;
currentMultiPersonChatIndex = chatIndex;
// 确保 chatHistory 存在
if (!chat.chatHistory) chat.chatHistory = [];
// 隐藏主页,显示聊天页
document.getElementById('wechat-main-content')?.classList.add('hidden');
document.getElementById('wechat-chat-page')?.classList.remove('hidden');
document.getElementById('wechat-chat-title').textContent = `${chat.name}(${chat.members.length})`;
const messagesContainer = document.getElementById('wechat-chat-messages');
const chatHistory = chat.chatHistory;
if (chatHistory.length === 0) {
messagesContainer.innerHTML = '';
} else {
messagesContainer.innerHTML = renderMultiPersonChatHistory(chat, chatHistory);
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 标记当前是多人群聊模式
messagesContainer.dataset.isMultiPerson = 'true';
messagesContainer.dataset.multiPersonIndex = chatIndex;
messagesContainer.dataset.isGroup = 'false'; // 区别于普通群聊
}
// 渲染多人群聊历史记录
function renderMultiPersonChatHistory(chat, chatHistory) {
let html = '';
let lastTimestamp = 0;
const TIME_GAP_THRESHOLD = 5 * 60 * 1000;
chatHistory.forEach((msg, index) => {
const msgTimestamp = msg.timestamp || 0;
// 时间戳显示
if (index === 0 || (msgTimestamp - lastTimestamp > TIME_GAP_THRESHOLD)) {
const timeLabel = formatMessageTime(msgTimestamp);
if (timeLabel) {
html += `<div class="wechat-msg-time">${timeLabel}</div>`;
}
}
lastTimestamp = msgTimestamp;
if (msg.role === 'user') {
// 用户消息:右对齐,有气泡
html += `
<div class="wechat-message self">
<div class="wechat-message-content">
<div class="wechat-message-bubble">${escapeHtml(msg.content)}</div>
</div>
</div>
`;
} else {
// 角色消息:无头像,名字+气泡,左对齐
const charName = msg.characterName || '未知';
html += `
<div class="wechat-message wechat-mp-message">
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(charName)}</div>
<div class="wechat-message-bubble">${escapeHtml(msg.content)}</div>
</div>
</div>
`;
}
});
return html;
}
// 追加多人群聊消息到界面
export function appendMultiPersonMessage(role, content, characterName = null) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
if (role === 'user') {
messageDiv.className = 'wechat-message self';
messageDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-message-bubble">${escapeHtml(content)}</div>
</div>
`;
} else {
messageDiv.className = 'wechat-message wechat-mp-message';
messageDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(characterName || '未知')}</div>
<div class="wechat-message-bubble">${escapeHtml(content)}</div>
</div>
`;
}
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 显示多人群聊打字指示器
export function showMultiPersonTypingIndicator(characterName) {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (!messagesContainer) return;
hideMultiPersonTypingIndicator();
const typingDiv = document.createElement('div');
typingDiv.className = 'wechat-message wechat-mp-message wechat-typing-wrapper';
typingDiv.id = 'wechat-mp-typing-indicator';
typingDiv.innerHTML = `
<div class="wechat-message-content">
<div class="wechat-mp-sender">${escapeHtml(characterName || '...')}</div>
<div class="wechat-message-bubble wechat-typing">
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
<span class="wechat-typing-dot"></span>
</div>
</div>
`;
messagesContainer.appendChild(typingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 隐藏多人群聊打字指示器
export function hideMultiPersonTypingIndicator() {
const indicator = document.getElementById('wechat-mp-typing-indicator');
if (indicator) indicator.remove();
}
// 构建多人群聊系统提示词
function buildMultiPersonSystemPrompt(chat, respondingMembers) {
const settings = getSettings();
let systemPrompt = '';
// 世界观(必读)
if (chat.worldView) {
systemPrompt += `【世界观设定】\n${chat.worldView}\n\n`;
}
// 参与角色信息
systemPrompt += `【参与角色】\n`;
systemPrompt += `这是一个包含 ${chat.members.length} 位角色的多人对话场景。\n\n`;
chat.members.forEach((member, idx) => {
systemPrompt += `角色 ${idx + 1}: ${member.name}\n`;
if (member.gender) systemPrompt += ` 性别: ${member.gender}\n`;
if (member.age) systemPrompt += ` 年龄: ${member.age}\n`;
if (member.description) systemPrompt += ` 描述: ${member.description}\n`;
systemPrompt += '\n';
});
// 本轮回复的角色
if (respondingMembers && respondingMembers.length > 0) {
systemPrompt += `【本轮发言角色】\n`;
systemPrompt += `本轮需要以下角色发言:${respondingMembers.map(m => m.name).join('、')}\n\n`;
}
// 回复格式说明
systemPrompt += `【回复格式】
你需要模拟多位角色的对话。请按以下格式回复:
[角色名]: 对话内容
如果有多个角色发言,请用 ||| 分隔每条消息。
示例:
[${chat.members[0]?.name || '角色A'}]: 你好啊 ||| [${chat.members[1]?.name || '角色B'}]: 嗨,好久不见
规则:
1. 每个角色保持自己的性格特点
2. 对话要自然流畅,像真实聊天
3. 每条消息简短自然1-3句话
4. 可以使用表情符号
5. 角色之间可以互相回应、互动
`;
return systemPrompt;
}
// 选择本轮回复的角色3-5人
function selectRespondingMembers(chat, userMessage) {
const members = chat.members || [];
const totalMembers = members.length;
// 根据群成员数量决定每轮回复人数
let respondCount;
if (totalMembers <= 5) {
// 5人及以下全部回复
respondCount = totalMembers;
} else if (totalMembers <= 10) {
// 6-10人每轮3-5人
respondCount = Math.min(5, Math.max(3, Math.floor(totalMembers * 0.5)));
} else {
// 10人以上每轮5人
respondCount = 5;
}
// 随机打乱成员顺序
const shuffled = [...members].sort(() => Math.random() - 0.5);
// 取前 respondCount 个
return shuffled.slice(0, respondCount);
}
// 调用多人群聊 AI
async function callMultiPersonAI(chat, userMessage, respondingMembers) {
const settings = getSettings();
// 使用全局 API 配置
const apiUrl = settings.apiUrl;
const apiKey = settings.apiKey;
const apiModel = settings.selectedModel;
if (!apiUrl || !apiModel) {
throw new Error('请先配置 AI 接口');
}
const systemPrompt = buildMultiPersonSystemPrompt(chat, respondingMembers);
const messages = [{ role: 'system', content: systemPrompt }];
// 添加历史消息
const chatHistory = chat.chatHistory || [];
const recentHistory = chatHistory.slice(-50);
recentHistory.forEach(msg => {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content });
} else {
const formattedContent = msg.characterName
? `[${msg.characterName}]: ${msg.content}`
: msg.content;
messages.push({ role: 'assistant', content: formattedContent });
}
});
messages.push({ role: 'user', content: userMessage });
const chatUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(chatUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: apiModel,
messages,
temperature: 1,
max_tokens: 4096
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API 错误 (${response.status}): ${errorText.substring(0, 100)}`);
}
const data = await response.json();
const rawResponse = data.choices?.[0]?.message?.content || '';
return parseMultiPersonResponse(rawResponse, chat.members);
}
// 解析多人群聊 AI 回复
function parseMultiPersonResponse(response, members) {
const results = [];
// 按 ||| 分隔多条消息
const parts = response.split('|||').map(p => p.trim()).filter(p => p);
parts.forEach(part => {
// 匹配 [角色名]: 内容 格式
const match = part.match(/^\[(.+?)\][:]\s*(.+)$/s);
if (match) {
const charName = match[1].trim();
const content = match[2].trim();
// 查找对应的成员
const member = members.find(m => m.name === charName);
results.push({
characterName: member?.name || charName,
content: content
});
} else {
// 无法解析格式时,作为第一个角色的消息
if (members.length > 0 && part.trim()) {
results.push({
characterName: members[0].name,
content: part.trim()
});
}
}
});
return results;
}
// 发送多人群聊消息
export async function sendMultiPersonMessage(messageText) {
console.log('[可乐] sendMultiPersonMessage 被调用', { messageText, currentMultiPersonChatIndex });
if (currentMultiPersonChatIndex < 0) return;
const settings = getSettings();
const chat = settings.multiPersonChats?.[currentMultiPersonChatIndex];
if (!chat) return;
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
const msgTimestamp = Date.now();
// 清空输入框
const input = document.getElementById('wechat-input');
if (input) input.value = '';
window.updateSendButtonState?.();
// 显示用户消息
appendMultiPersonMessage('user', messageText);
// 确保 chatHistory 存在
if (!chat.chatHistory) chat.chatHistory = [];
// 添加到历史
chat.chatHistory.push({
role: 'user',
content: messageText,
time: timeStr,
timestamp: msgTimestamp
});
// 立即保存
saveNow();
// 选择本轮回复的角色
const respondingMembers = selectRespondingMembers(chat, messageText);
// 显示第一个角色的打字指示器
showMultiPersonTypingIndicator(respondingMembers[0]?.name);
try {
// 调用 AI
const responses = await callMultiPersonAI(chat, messageText, respondingMembers);
hideMultiPersonTypingIndicator();
// 逐条显示 AI 回复,带 typing 效果
for (let i = 0; i < responses.length; i++) {
const resp = responses[i];
// 显示 typing 指示器
showMultiPersonTypingIndicator(resp.characterName);
await sleep(600 + Math.random() * 400); // 0.6-1秒
hideMultiPersonTypingIndicator();
// 添加到历史
chat.chatHistory.push({
role: 'assistant',
content: resp.content,
characterName: resp.characterName,
time: timeStr,
timestamp: Date.now()
});
// 显示消息
appendMultiPersonMessage('assistant', resp.content, resp.characterName);
}
// 更新最后消息
if (responses.length > 0) {
const lastResp = responses[responses.length - 1];
chat.lastMessage = `[${lastResp.characterName}]: ${lastResp.content}`;
}
chat.lastMessageTime = Date.now();
requestSave();
refreshChatList();
} catch (err) {
hideMultiPersonTypingIndicator();
console.error('[可乐] 多人群聊 AI 调用失败:', err);
appendMultiPersonMessage('assistant', `⚠️ ${err.message}`, '系统');
requestSave();
}
}
// 判断当前是否在多人群聊
export function isInMultiPersonChat() {
const messagesContainer = document.getElementById('wechat-chat-messages');
return messagesContainer?.dataset.isMultiPerson === 'true';
}
// 获取当前多人群聊索引
export function getCurrentMultiPersonIndex() {
const messagesContainer = document.getElementById('wechat-chat-messages');
if (messagesContainer?.dataset.isMultiPerson === 'true') {
const index = parseInt(messagesContainer.dataset.multiPersonIndex);
return isNaN(index) ? -1 : index;
}
return -1;
}

View File

@@ -118,6 +118,10 @@ export function generatePhoneHTML() {
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" 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="M7 16.5c0-2 2.2-3.5 5-3.5s5 1.5 5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
<span id="wechat-floating-ball-text">悬浮窗</span>
</div>
<div class="wechat-dropdown-item" id="wechat-menu-clear-cache">
<svg viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
<span>清除缓存</span>
</div>
</div>
<!-- 添加朋友页面 -->
@@ -150,6 +154,13 @@ export function generatePhoneHTML() {
<div class="wechat-add-option-text">导入角色卡 (JSON)</div>
<span class="wechat-add-option-arrow"></span>
</div>
<div class="wechat-add-option" id="wechat-import-multi-card">
<div class="wechat-add-option-icon">
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
</div>
<div class="wechat-add-option-text">导入多人卡</div>
<span class="wechat-add-option-arrow"></span>
</div>
</div>
</div>
</div>
@@ -167,6 +178,14 @@ export function generatePhoneHTML() {
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
<span>撤回消息</span>
</div>
<div class="wechat-dropdown-item hidden" id="wechat-menu-invite-member">
<svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M3 21v-2a4 4 0 014-4h4a4 4 0 014 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M19 8v6M16 11h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>邀请成员</span>
</div>
<div class="wechat-dropdown-item" id="wechat-menu-chat-bg">
<svg viewBox="0 0 24 24" width="18" height="18"><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" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
<span>聊天背景</span>
@@ -228,15 +247,16 @@ export function generatePhoneHTML() {
<div class="wechat-func-item" data-func="photo"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/><path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>照片</span></div>
<div class="wechat-func-item" data-func="voicecall"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 005 5L15 13l5 2v4a2 2 0 01-2 2A16 16 0 013 6a2 2 0 012-2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></div><span>语音通话</span></div>
<div class="wechat-func-item" data-func="videocall"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="13" height="12" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M22 8l-7 4 7 4V8z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>视频通话</span></div>
<div class="wechat-func-item" data-func="realvoice"><div class="wechat-func-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>实时语音</span></div>
<div class="wechat-func-item" data-func="location"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="9" r="2.5" fill="currentColor"/></svg></div><span>位置</span></div>
<div class="wechat-func-item" data-func="redpacket"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="4" y="2" width="16" height="20" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M4 8h16" stroke="currentColor" stroke-width="1.5"/></svg></div><span>红包</span></div>
<div class="wechat-func-item" data-func="gift"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="3" y="8" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M12 8v13M3 12h18" stroke="currentColor" stroke-width="1.5"/><path d="M12 8c-2-4-6-4-6 0s4 0 6 0c2 0 6-4 6 0s-4 4-6 0" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>礼物</span></div>
<div class="wechat-func-item" data-func="transfer"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M2 10h20" stroke="currentColor" stroke-width="1.5"/><path d="M6 15h4M14 15h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>转账</span></div>
<div class="wechat-func-item" data-func="multi"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>多条消息</span></div>
</div>
</div>
<div class="wechat-func-page" data-page="1">
<div class="wechat-func-grid">
<div class="wechat-func-item" data-func="multi"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 9h8M8 13h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></div><span>多条消息</span></div>
<div class="wechat-func-item" data-func="voice"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音输入</span></div>
<div class="wechat-func-item" data-func="listen"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M3 18v-6a9 9 0 0118 0v6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3v5z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3v5z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>一起听</span></div>
<div class="wechat-func-item" data-func="music"><div class="wechat-func-icon"><svg viewBox="0 0 24 24"><path d="M9 18V5l12-2v13" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="18" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="18" cy="16" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>音乐</span></div>
@@ -292,6 +312,7 @@ export function generatePhoneHTML() {
${generateModalsHTML(settings)}
${generateVoiceCallPageHTML()}
${generateVideoCallPageHTML()}
${generateRealVoiceCallPageHTML()}
${generateMusicPanelHTML()}
${generateListenTogetherHTML()}
${generateMomentsPageHTML()}
@@ -767,6 +788,15 @@ function generateServicePageHTML(settings) {
<div class="wechat-service-section-title">AI功能</div>
<div class="wechat-service-grid">
<div class="wechat-service-item" data-service="meme-stickers"><div class="wechat-service-icon purple" style="background: linear-gradient(135deg, #9c27b0, #e91e63);"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="9" r="1.5" fill="currentColor"/><path d="M7 14c1.5 3 4 4 5 4s3.5-1 5-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>Meme表情</span></div>
<div class="wechat-service-item" data-service="voice-api"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #00bcd4, #009688);"><svg viewBox="0 0 24 24"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg></div><span>语音API</span></div>
<div class="wechat-service-item" data-service="multi-char-table"><div class="wechat-service-icon" style="background: linear-gradient(135deg, #3f51b5, #7986cb);"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></div><span>多人卡表格</span></div>
</div>
<!-- 角色表格容器(可折叠) -->
<div id="wechat-char-tables-section" class="hidden">
<div class="wechat-service-section-title" style="margin-top: 16px;">已解析的角色表格</div>
<div id="wechat-char-tables-container">
<!-- 角色表格由 JS 动态填充 -->
</div>
</div>
</div>
<div class="wechat-service-section">
@@ -802,6 +832,77 @@ function generateServicePageHTML(settings) {
</div>
</div>
</div>
<!-- 语音 API 设置面板 -->
<div class="wechat-service-panel hidden" id="wechat-voice-api-panel">
<div class="wechat-panel-header">
<span class="wechat-panel-title">语音 API 设置</span>
<button class="wechat-panel-close" data-panel="wechat-voice-api-panel">×</button>
</div>
<div style="padding: 16px; max-height: 70vh; overflow-y: auto;">
<div style="font-size: 13px; font-weight: bold; color: #00bcd4; margin-bottom: 10px;">语音识别 (STT)</div>
<div style="font-size: 11px; color: var(--wechat-text-secondary); margin-bottom: 8px;">将语音转换为文字</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 地址</div>
<input type="text" class="wechat-settings-input" id="wechat-stt-api-url" placeholder="https://api.example.com/v1/audio/transcriptions" value="${settings.sttApiUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 密钥</div>
<div class="wechat-settings-input-wrapper">
<input type="password" class="wechat-settings-input" id="wechat-stt-api-key" placeholder="sk-..." value="${settings.sttApiKey || ''}" style="width: 100%; box-sizing: border-box;">
<button class="wechat-settings-eye-btn" id="wechat-stt-key-toggle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/></svg></button>
</div>
</div>
<div style="margin-bottom: 16px;">
<div style="font-size: 12px; margin-bottom: 4px;">模型</div>
<input type="text" class="wechat-settings-input" id="wechat-stt-model" placeholder="whisper-1 或 iic/SenseVoiceSmall" value="${settings.sttModel || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="border-top: 1px solid var(--wechat-border); margin: 16px 0;"></div>
<div style="font-size: 13px; font-weight: bold; color: #009688; margin-bottom: 10px;">语音合成 (TTS)</div>
<div style="font-size: 11px; color: var(--wechat-text-secondary); margin-bottom: 8px;">将文字转换为语音</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 地址</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-api-url" placeholder="https://api.example.com/v1/audio/speech" value="${settings.ttsApiUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">API 密钥</div>
<div class="wechat-settings-input-wrapper">
<input type="password" class="wechat-settings-input" id="wechat-tts-api-key" placeholder="sk-..." value="${settings.ttsApiKey || ''}" style="width: 100%; box-sizing: border-box;">
<button class="wechat-settings-eye-btn" id="wechat-tts-key-toggle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/></svg></button>
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">模型</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-model" placeholder="gemini-2.5-flash-preview-tts / tts-1" value="${settings.ttsModel || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">音色</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-voice" placeholder="achird / alloy / nova" value="${settings.ttsVoice || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="display: flex; gap: 10px; margin-bottom: 12px;">
<div style="flex: 1;">
<div style="font-size: 12px; margin-bottom: 4px;">语速</div>
<input type="number" class="wechat-settings-input" id="wechat-tts-speed" placeholder="1.0" value="${settings.ttsSpeed || 1}" min="0.5" max="2" step="0.1" style="width: 100%; box-sizing: border-box;">
</div>
<div style="flex: 1;">
<div style="font-size: 12px; margin-bottom: 4px;">情感</div>
<input type="text" class="wechat-settings-input" id="wechat-tts-emotion" placeholder="默认" value="${settings.ttsEmotion || '默认'}" style="width: 100%; box-sizing: border-box;">
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="font-size: 12px; margin-bottom: 4px;">代理 URL <span style="color: #999; font-weight: normal;">(MiniMax 需要)</span></div>
<input type="text" class="wechat-settings-input" id="wechat-tts-proxy-url" placeholder="http://你的服务器:3001" value="${settings.ttsProxyUrl || ''}" style="width: 100%; box-sizing: border-box;">
</div>
<div style="display: flex; gap: 10px; margin-top: 16px;">
<button class="wechat-btn wechat-btn-small" id="wechat-voice-api-test-stt" style="flex: 1; background: #00bcd4; color: white;">测试 STT</button>
<button class="wechat-btn wechat-btn-small" id="wechat-voice-api-test-tts" style="flex: 1; background: #009688; color: white;">测试 TTS</button>
</div>
<button class="wechat-btn wechat-btn-primary wechat-btn-block" id="wechat-voice-api-save" style="margin-top: 10px;">保存配置</button>
<div id="wechat-voice-api-status" style="font-size: 12px; color: var(--wechat-text-secondary); margin-top: 8px; text-align: center;"></div>
</div>
</div>
</div>
</div>
`;
@@ -936,7 +1037,7 @@ function generateModalsHTML(settings) {
<div id="wechat-group-contacts-list" style="max-height: 300px; overflow-y: auto; border: 1px solid var(--wechat-border); border-radius: 8px; padding: 8px;"></div>
<div style="margin-top: 12px; text-align: center; color: var(--wechat-text-secondary); font-size: 13px;">
已选择 <span id="wechat-group-selected-count" style="color: var(--wechat-primary); font-weight: 500;">0</span> 人
已选择 <span id="wechat-group-selected-count" style="color: var(--wechat-green); font-weight: 500;">0</span> 人
</div>
<div class="wechat-modal-actions" style="margin-top: 16px;">
@@ -944,6 +1045,88 @@ function generateModalsHTML(settings) {
</div>
</div>
</div>
<!-- 语音回放选择弹窗 -->
<div id="wechat-voice-save-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="position: relative; max-width: 400px;">
<button class="wechat-modal-close-x" id="wechat-voice-save-cancel" title="关闭">×</button>
<div class="wechat-modal-title">保存语音回放</div>
<div class="wechat-voice-save-hint" style="font-size: 12px; color: var(--wechat-text-secondary); margin-bottom: 12px;">
选择想保留的语音,以后可以在聊天记录中回放
</div>
<div class="wechat-voice-save-list" id="wechat-voice-save-list" style="max-height: 300px; overflow-y: auto;">
<!-- 语音列表将动态生成 -->
</div>
<div class="wechat-modal-actions" style="margin-top: 16px; display: flex; gap: 10px;">
<button class="wechat-btn" id="wechat-voice-save-skip" style="flex: 1;">不保存</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-voice-save-confirm" style="flex: 1;">保存选中</button>
</div>
</div>
</div>
<!-- 多人群聊配置弹窗 -->
<div id="wechat-mp-api-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="position: relative; max-width: 380px; max-height: 85vh; overflow-y: auto;">
<button class="wechat-modal-close-x" id="wechat-mp-api-close">×</button>
<div class="wechat-modal-title">群聊设置</div>
<!-- 头像和群名编辑区 -->
<div class="wechat-settings-group" style="padding: 12px; background: var(--wechat-bg-secondary); border-radius: 8px; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<div id="wechat-mp-avatar-preview" style="width: 60px; height: 60px; border-radius: 8px; overflow: hidden; background: #fff; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; color: #000; cursor: pointer; border: 1px solid #ddd;" title="点击更换头像">群</div>
<div style="flex: 1;">
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">群聊名称</span>
<input type="text" class="wechat-settings-input" id="wechat-mp-name-input" placeholder="群聊" style="width: 100%; box-sizing: border-box;">
</div>
</div>
<button class="wechat-btn wechat-btn-small" id="wechat-mp-change-avatar" style="width: 100%;">更换头像</button>
<input type="file" id="wechat-mp-avatar-file" accept="image/*" style="display: none;">
</div>
<!-- API配置区 -->
<div class="wechat-settings-group" style="padding: 12px; background: var(--wechat-bg-secondary); border-radius: 8px; margin-bottom: 12px;">
<div class="wechat-settings-item" style="margin-bottom: 12px;">
<span class="wechat-settings-label">使用独立API</span>
<div class="wechat-switch" id="wechat-mp-use-custom-api"></div>
</div>
<div id="wechat-mp-global-tip" style="font-size: 12px; color: var(--wechat-text-secondary);">
将使用全局 AI 配置
</div>
<div id="wechat-mp-api-config" class="hidden" style="display: flex; flex-direction: column; gap: 10px;">
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">API 地址</span>
<input type="text" class="wechat-settings-input" id="wechat-mp-api-url" placeholder="https://api.example.com/v1" style="width: 100%; box-sizing: border-box;">
</div>
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">API 密钥</span>
<input type="password" class="wechat-settings-input" id="wechat-mp-api-key" placeholder="sk-xxx" style="width: 100%; box-sizing: border-box;">
</div>
<div>
<span class="wechat-settings-label" style="font-size: 12px; margin-bottom: 4px; display: block;">模型</span>
<div style="display: flex; gap: 8px;" id="wechat-mp-model-select-wrapper">
<select class="wechat-settings-input" id="wechat-mp-model-select" style="flex: 1; box-sizing: border-box;">
<option value="">---请选择模型---</option>
</select>
<button class="wechat-btn wechat-btn-small" id="wechat-mp-model-manual" style="white-space: nowrap;">手动</button>
<button class="wechat-btn wechat-btn-small wechat-btn-primary" id="wechat-mp-fetch-model" style="white-space: nowrap;">获取</button>
</div>
<div style="display: none; gap: 8px;" id="wechat-mp-model-input-wrapper">
<input type="text" class="wechat-settings-input" id="wechat-mp-model-input" placeholder="手动输入模型名称" style="flex: 1; box-sizing: border-box;">
<button class="wechat-btn wechat-btn-small" id="wechat-mp-model-back" style="white-space: nowrap;">返回</button>
</div>
</div>
<div style="display: flex; gap: 8px; margin-top: 4px;">
<button class="wechat-btn wechat-btn-small" id="wechat-mp-test-api" style="flex: 1;">测试连接</button>
</div>
</div>
</div>
<div class="wechat-modal-actions">
<button class="wechat-btn wechat-btn-primary" id="wechat-mp-api-save">保存</button>
</div>
</div>
</div>
`;
}
@@ -969,12 +1152,14 @@ function generateVoiceCallPageHTML() {
<!-- 通话中对话框 -->
<div class="wechat-voice-call-chat hidden" id="wechat-voice-call-chat">
<div class="wechat-voice-call-messages" id="wechat-voice-call-messages"></div>
<div class="wechat-voice-call-input-area">
<input type="text" class="wechat-voice-call-input" id="wechat-voice-call-input" placeholder="输入文字...">
<button class="wechat-voice-call-send" id="wechat-voice-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<!-- 通话输入框(独立于对话框,放在按钮上方) -->
<div class="wechat-voice-call-input-area hidden" id="wechat-voice-call-input-area">
<input type="text" class="wechat-voice-call-input" id="wechat-voice-call-input" placeholder="输入文字...">
<button class="wechat-voice-call-send" id="wechat-voice-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<!-- 来电接听按钮AI发起时显示 -->
@@ -1050,12 +1235,14 @@ function generateVideoCallPageHTML() {
<!-- 通话中对话框 -->
<div class="wechat-video-call-chat hidden" id="wechat-video-call-chat">
<div class="wechat-video-call-messages" id="wechat-video-call-messages"></div>
<div class="wechat-video-call-input-area">
<input type="text" class="wechat-video-call-input" id="wechat-video-call-input" placeholder="输入文字...">
<button class="wechat-video-call-send" id="wechat-video-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
<!-- 通话输入框(独立于对话框,放在按钮上方) -->
<div class="wechat-video-call-input-area hidden" id="wechat-video-call-input-area">
<input type="text" class="wechat-video-call-input" id="wechat-video-call-input" placeholder="输入文字...">
<button class="wechat-video-call-send" id="wechat-video-call-send">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<!-- 底部操作栏 -->
@@ -1113,6 +1300,82 @@ function generateVideoCallPageHTML() {
`;
}
// 实时语音通话页面 HTML
function generateRealVoiceCallPageHTML() {
return `
<!-- 实时语音通话页面 -->
<div id="wechat-real-voice-call-page" class="wechat-real-voice-call-page hidden">
<div class="wechat-real-voice-call-header">
<button class="wechat-real-voice-call-minimize" id="wechat-real-voice-call-minimize">
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M9 3v18M3 9h6" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<span class="wechat-real-voice-call-time hidden" id="wechat-real-voice-call-time">00:00</span>
<span style="width: 24px;"></span>
</div>
<div class="wechat-real-voice-call-content">
<div class="wechat-real-voice-call-avatar" id="wechat-real-voice-call-avatar"></div>
<div class="wechat-real-voice-call-name" id="wechat-real-voice-call-name"></div>
<div class="wechat-real-voice-call-status" id="wechat-real-voice-call-status">等待对方接受邀请</div>
</div>
<!-- 通话中消息显示区域 -->
<div class="wechat-real-voice-call-chat hidden" id="wechat-real-voice-call-chat">
<div class="wechat-real-voice-call-messages" id="wechat-real-voice-call-messages"></div>
</div>
<!-- 说话按钮区域 -->
<div class="wechat-real-voice-call-talk-area hidden" id="wechat-real-voice-call-talk-area">
<div class="wechat-real-voice-call-talk-btn" id="wechat-real-voice-call-talk-btn">点击 说话</div>
<div class="wechat-real-voice-call-talk-hint">点击开始,再点击发送</div>
<!-- 文字输入区域(不支持录音时使用) -->
<div class="wechat-real-voice-call-text-input-area" id="wechat-real-voice-call-text-input-area">
<input type="text" class="wechat-real-voice-call-text-input" id="wechat-real-voice-call-text-input" placeholder="打字说话...">
<button class="wechat-real-voice-call-text-send" id="wechat-real-voice-call-text-send">发送</button>
</div>
</div>
<!-- 来电接听按钮AI发起时显示 -->
<div class="wechat-real-voice-call-incoming-actions hidden" id="wechat-real-voice-call-incoming-actions">
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-reject">
<div class="wechat-real-voice-call-action-btn reject">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" fill="currentColor"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">拒绝</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-accept">
<div class="wechat-real-voice-call-action-btn accept">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">接听</span>
</div>
</div>
<!-- 通话中操作按钮(接通后显示) -->
<div class="wechat-real-voice-call-actions hidden" id="wechat-real-voice-call-actions">
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-mute">
<div class="wechat-real-voice-call-action-btn">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 1a4 4 0 00-4 4v7a4 4 0 008 0V5a4 4 0 00-4-4z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">静音</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-hangup">
<div class="wechat-real-voice-call-action-btn hangup">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" fill="currentColor"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">挂断</span>
</div>
<div class="wechat-real-voice-call-action" id="wechat-real-voice-call-speaker">
<div class="wechat-real-voice-call-action-btn">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M11 5L6 9H2v6h4l5 4V5z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/></svg>
</div>
<span class="wechat-real-voice-call-action-label">扬声器</span>
</div>
</div>
</div>
`;
}
// 朋友圈页面 HTML
function generateMomentsPageHTML() {
return `
@@ -1337,6 +1600,161 @@ function generateGiftPageHTML() {
</div>
</div>
<!-- 多人卡导入弹窗 -->
<div id="wechat-multi-import-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 420px; position: relative;">
<button class="wechat-modal-close-x" id="wechat-multi-import-close">×</button>
<div class="wechat-modal-header">
<span>导入多人卡</span>
</div>
<div class="wechat-modal-body">
<!-- AI 配置区 -->
<div class="wechat-settings-section">
<div class="wechat-settings-title">解析 AI 配置</div>
<!-- 使用独立API开关 -->
<div class="wechat-settings-row">
<span>使用独立API</span>
<div class="wechat-switch" id="wechat-multi-import-custom-api"></div>
</div>
<!-- API配置默认隐藏 -->
<div id="wechat-multi-import-api-config" class="hidden" style="margin-top: 12px;">
<div class="wechat-settings-item">
<label>API 地址</label>
<input type="text" class="wechat-settings-input"
id="wechat-multi-import-api-url"
placeholder="https://api.example.com/v1">
</div>
<div class="wechat-settings-item">
<label>API 密钥</label>
<input type="password" class="wechat-settings-input"
id="wechat-multi-import-api-key"
placeholder="sk-...">
</div>
<div class="wechat-settings-item">
<label>模型</label>
<div style="display: flex; gap: 8px;">
<div id="wechat-multi-import-model-select-wrapper" style="flex: 1; display: flex;">
<select class="wechat-settings-input wechat-settings-select"
id="wechat-multi-import-model-select" style="flex: 1;">
<option value="">--请选择模型--</option>
</select>
</div>
<div id="wechat-multi-import-model-input-wrapper" style="flex: 1; display: none;">
<input type="text" class="wechat-settings-input"
id="wechat-multi-import-model-input"
placeholder="手动输入模型名">
</div>
<button class="wechat-btn wechat-btn-small" id="wechat-multi-import-model-toggle">手动</button>
<button class="wechat-btn wechat-btn-small wechat-btn-primary" id="wechat-multi-import-fetch-model">获取</button>
</div>
</div>
<button class="wechat-btn" id="wechat-multi-import-test" style="width: 100%; margin-top: 8px;">
测试连接
</button>
</div>
<!-- 使用全局配置提示 -->
<div id="wechat-multi-import-global-tip" style="margin-top: 8px; font-size: 12px; color: var(--wechat-text-secondary);">
将使用全局 AI 配置进行解析
</div>
</div>
<!-- 文件选择区 -->
<div class="wechat-settings-section" style="margin-top: 16px;">
<div class="wechat-settings-title">选择文件</div>
<div style="display: flex; gap: 10px;">
<button class="wechat-btn" id="wechat-multi-import-select-png" style="flex: 1;">
选择 PNG 文件
</button>
<button class="wechat-btn" id="wechat-multi-import-select-json" style="flex: 1;">
选择 JSON 文件
</button>
</div>
<div id="wechat-multi-import-file-info" style="margin-top: 8px; font-size: 13px; color: var(--wechat-text-secondary);">
未选择文件
</div>
</div>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-multi-import-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-multi-import-start" disabled>开始解析</button>
</div>
</div>
</div>
<!-- 角色选择弹窗(选择导入哪些角色为联系人/群聊) -->
<div id="wechat-char-select-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 450px; max-height: 80vh; display: flex; flex-direction: column;">
<div class="wechat-modal-header">
<span>选择要导入的角色</span>
<span class="wechat-modal-close" id="wechat-char-select-close">&times;</span>
</div>
<div class="wechat-modal-body" style="flex: 1; overflow-y: auto; padding: 0;">
<!-- 角色列表区 -->
<div style="padding: 12px; border-bottom: 1px solid var(--wechat-border);">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<input type="checkbox" id="wechat-char-select-all" checked style="margin-right: 8px;">
<label for="wechat-char-select-all" style="font-weight: bold;">创建独立联系人</label>
<span id="wechat-char-select-count" style="margin-left: auto; font-size: 12px; color: var(--wechat-text-secondary);">0/0</span>
</div>
<div id="wechat-char-select-list" style="max-height: 250px; overflow-y: auto;">
<!-- 角色列表动态填充 -->
</div>
</div>
<!-- 群聊选项区 -->
<div style="padding: 12px;">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<input type="checkbox" id="wechat-char-select-group" checked style="margin-right: 8px;">
<label for="wechat-char-select-group" style="font-weight: bold;">同时创建群聊</label>
</div>
<div id="wechat-char-select-group-options">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<div id="wechat-char-select-group-avatar" style="width: 48px; height: 48px; background: #fff; border: 1px solid #ddd; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; color: #000;">群</div>
<input type="text" id="wechat-char-select-group-name" class="wechat-settings-input" placeholder="群聊名称(可选)" style="flex: 1;">
</div>
<div style="font-size: 12px; color: var(--wechat-text-secondary);">
将包含上方勾选的联系人至少需要2人
</div>
</div>
</div>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-char-select-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-char-select-confirm">确认导入</button>
</div>
</div>
</div>
<!-- "其它信息"编辑弹窗 -->
<div id="wechat-char-other-edit-modal" class="wechat-modal hidden">
<div class="wechat-modal-content" style="max-width: 400px;">
<div class="wechat-modal-header">
<span id="wechat-char-other-edit-title">编辑其它信息</span>
<span class="wechat-modal-close" id="wechat-char-other-edit-close">&times;</span>
</div>
<div class="wechat-modal-body" style="padding: 16px;">
<textarea id="wechat-char-other-edit-textarea"
class="wechat-settings-input"
style="width: 100%; height: 200px; resize: vertical; font-size: 14px; line-height: 1.5;"
placeholder="其它信息"></textarea>
</div>
<div class="wechat-modal-footer">
<button class="wechat-btn" id="wechat-char-other-edit-cancel">取消</button>
<button class="wechat-btn wechat-btn-primary" id="wechat-char-other-edit-save">保存</button>
</div>
</div>
</div>
<!-- 玩具控制页面 -->
<div id="wechat-toy-control-page" class="wechat-toy-control-page hidden">
<div class="wechat-navbar wechat-toy-control-navbar">
@@ -1654,11 +2072,12 @@ function generateHistoryPageHTML() {
<div style="width: 24px;"></div>
</div>
<!-- 个标签按钮 -->
<!-- 个标签按钮 -->
<div class="wechat-history-tabs">
<button class="wechat-history-tab active" data-tab="listen">一起听</button>
<button class="wechat-history-tab" data-tab="voice">语音通话</button>
<button class="wechat-history-tab" data-tab="video">视频通话</button>
<button class="wechat-history-tab wechat-history-tab-green" data-tab="playback">语音回放</button>
<button class="wechat-history-tab wechat-history-tab-pink" data-tab="toy">心动瞬间</button>
</div>

308
proactive-message.js Normal file
View File

@@ -0,0 +1,308 @@
/**
* 角色主动发消息系统
* 规则每2-3轮随机触发保底4轮必触发
*/
import { requestSave } from './save-manager.js';
import { getSettings, splitAIMessages } from './config.js';
import { refreshChatList } from './ui.js';
import { showNotificationBanner } from './toast.js';
import { buildSystemPrompt } from './ai.js';
// 配置
const CONFIG = {
minRounds: 2, // 最少2轮后可触发
maxRounds: 3, // 随机2-3轮
guaranteeRounds: 4, // 保底4轮必触发
cooldownMs: 30 * 1000, // 30秒冷却防止刷屏
groupEmotionChance: 0.3 // 群聊情绪触发概率30%
};
/**
* 生成随机阈值 (2-3)
*/
function randomThreshold() {
return CONFIG.minRounds + Math.floor(Math.random() * (CONFIG.maxRounds - CONFIG.minRounds + 1));
}
/**
* 格式化时间字符串
*/
function formatTimeStr(date) {
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
/**
* 发送主动消息
* @param {object} contact - 联系人对象
* @param {string} type - 消息类型:'daily' | 'angry_private' | 'want_private'
* @param {Array} groupContext - 群聊上下文(可选,用于群聊触发的私聊)
*/
async function sendProactiveMessage(contact, type = 'daily', groupContext = []) {
const prompts = {
// 日常主动消息
daily: `[你现在要主动给用户发一条消息。可以是:
1. 分享你正在做的事情
2. 想起用户了,打个招呼
3. 看到什么有趣的东西想分享
4. 撒娇或关心用户
根据你的性格和当前心情发1-2条简短消息像真实聊天一样自然。]`,
// 群聊生气后私下发
angry_private: `[你刚才在群聊里和用户有些不愉快,现在想私下和用户说点什么。
可以是:生气、委屈、想解释、想和好,或者继续吵。
根据你的性格决定态度发1-2条消息。]`,
// 群聊中想私聊
want_private: `[你在群聊里聊到一些话题,想私下单独和用户聊点事情。
发一条消息说明原因,像"有件事想单独跟你说"这样自然的开场。]`,
};
try {
// 如果是群聊触发的私聊,需要特殊处理
if ((type === 'angry_private' || type === 'want_private') && groupContext.length > 0) {
// 使用带群聊上下文的 AI 调用
const response = await callAIWithGroupContext(contact, prompts[type], groupContext);
await processProactiveResponse(contact, response, type);
} else {
// 普通主动消息,使用标准 callAI
const { callAI } = await import('./ai.js');
const response = await callAI(contact, prompts[type] || prompts.daily);
await processProactiveResponse(contact, response, type);
}
} catch (err) {
console.error('[可乐] 主动消息发送失败:', err);
}
}
/**
* 处理主动消息的响应
*/
async function processProactiveResponse(contact, response, type) {
const messages = splitAIMessages(response);
const now = new Date();
const timeStr = formatTimeStr(now);
if (!contact.chatHistory) contact.chatHistory = [];
for (const msg of messages) {
const content = msg.trim();
if (!content) continue;
contact.chatHistory.push({
role: 'assistant',
content: content,
time: timeStr,
timestamp: Date.now(),
isProactive: true // 标记为主动消息
});
contact.unreadCount = (contact.unreadCount || 0) + 1;
contact.lastMessage = content;
}
requestSave();
refreshChatList();
// 显示通知横幅
const previewText = messages[0]?.substring(0, 15) || '';
showNotificationBanner('微信', `${contact.name}: ${previewText}${previewText.length >= 15 ? '...' : ''}`);
console.log(`[可乐] ${contact.name} 主动发消息 (${type})`);
}
/**
* 带群聊上下文的 AI 调用
* 用于群聊触发的私聊确保AI知道群里发生了什么
* @param {object} contact - 联系人对象
* @param {string} userMessage - 用户消息(提示词)
* @param {Array} groupContext - 群聊上下文
*/
async function callAIWithGroupContext(contact, userMessage, groupContext) {
const { getApiConfig, fetchWithRetry, formatApiError } = await import('./ai.js');
const settings = getSettings();
// 获取 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 systemPrompt = buildSystemPrompt(contact);
// 构建消息数组
const messages = [{ role: 'system', content: systemPrompt }];
// 添加群聊上下文(作为背景信息)
if (groupContext.length > 0) {
// 将群聊上下文格式化为一条系统消息
const groupContextText = groupContext.map(msg => {
const sender = msg.characterName || (msg.role === 'user' ? '用户' : '未知');
return `${sender}: ${msg.content}`;
}).join('\n');
messages.push({
role: 'user',
content: `[以下是刚才群聊中的对话记录,你需要根据这些内容来决定私聊时说什么]\n\n${groupContextText}\n\n[群聊记录结束]`
});
messages.push({
role: 'assistant',
content: '好的,我已经了解了群聊中发生的事情。'
});
}
// 添加私聊历史记录最近10条让AI知道私聊的上下文
const chatHistory = contact.chatHistory || [];
const recentPrivateHistory = chatHistory.slice(-10);
recentPrivateHistory.forEach(msg => {
if (msg.isMarker) return;
messages.push({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
});
});
// 添加当前提示词
messages.push({ role: 'user', content: userMessage });
// 调用 API
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: 8196
})
},
{ maxRetries: 3 }
);
if (!response.ok) {
throw new Error(await formatApiError(response, { retries: 0 }));
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '...';
}
/**
* 用户发消息后调用,检查其他联系人是否要主动发消息
* @param {string} currentContactId - 当前聊天的联系人ID
*/
export async function checkOtherContactsProactive(currentContactId) {
const settings = getSettings();
for (const contact of settings.contacts) {
// 跳过当前聊天的联系人
if (contact.id === currentContactId) continue;
// 跳过被拉黑的
if (contact.isBlocked) continue;
// 跳过没有聊过天的(避免陌生人突然发消息)
if (!contact.chatHistory || contact.chatHistory.length === 0) continue;
// 初始化计数器
if (typeof contact.proactiveCounter !== 'number') {
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
}
// 递增计数
contact.proactiveCounter++;
// 检查是否触发
const shouldTrigger =
contact.proactiveCounter >= CONFIG.guaranteeRounds || // 保底4轮
contact.proactiveCounter >= contact.proactiveThreshold; // 随机阈值
if (!shouldTrigger) continue;
// 检查冷却时间
if (Date.now() - (contact.lastProactiveAt || 0) < CONFIG.cooldownMs) {
continue;
}
// 重置计数器和阈值
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
contact.lastProactiveAt = Date.now();
// 触发主动消息
await sendProactiveMessage(contact, 'daily');
}
requestSave();
}
/**
* 群聊中检测到情绪后调用
* @param {string} contactId - 联系人ID
* @param {string} emotionType - 情绪类型:'negative' | 'want_private'
* @param {Array} groupContext - 群聊上下文最近40条消息
*/
export async function triggerProactiveFromGroup(contactId, emotionType, groupContext = []) {
const settings = getSettings();
const contact = settings.contacts.find(c => c.id === contactId);
if (!contact || contact.isBlocked) return;
// 检查冷却
if (Date.now() - (contact.lastProactiveAt || 0) < CONFIG.cooldownMs) {
return;
}
// 群聊情绪触发有独立的概率
if (Math.random() > CONFIG.groupEmotionChance) {
console.log(`[可乐] ${contact.name} 群聊情绪触发未命中概率 (${CONFIG.groupEmotionChance * 100}%)`);
return;
}
contact.lastProactiveAt = Date.now();
requestSave();
// 立即发送,传递群聊上下文
const messageType = emotionType === 'negative' ? 'angry_private' : 'want_private';
console.log(`[可乐] ${contact.name} 群聊情绪触发私聊 (${messageType}),群聊上下文 ${groupContext.length}`);
await sendProactiveMessage(contact, messageType, groupContext);
}
/**
* 重置某个联系人的主动消息计数器
* @param {string} contactId - 联系人ID
*/
export function resetProactiveCounter(contactId) {
const settings = getSettings();
const contact = settings.contacts.find(c => c.id === contactId);
if (contact) {
contact.proactiveCounter = 0;
contact.proactiveThreshold = randomThreshold();
requestSave();
}
}
export { sendProactiveMessage };

1233
real-voice-call.js Normal file

File diff suppressed because it is too large Load Diff

1358
style.css

File diff suppressed because it is too large Load Diff

View File

@@ -311,23 +311,23 @@ export function generateSummaryPrompt(allChats, cupNumber) {
prompt = settings.customSummaryTemplate.trim() + '\n\n';
} else {
// 使用默认模板(纯对话记录模式)
prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式
prompt = `【重要】你必须且只能输出一个JSON对象禁止输出任何其他内容
【核心原则】
- 原样保留:完整复制每一条对话,不做任何修改、润色或总结
- 格式统一:按"发言者: 内容"格式逐行记录
- 仅提取关键词从对话中提取3-5个核心关键词用于检索触发
你的任务是将聊天记录整理成JSON格式。
【输出格式要求
- 只输出一个JSON对象
- 不要使用markdown代码块
- 直接以 { 开头,以 } 结尾
- keys: 3-5个能代表本次聊天核心内容的关键词人名、地点、事件等
- content: 以"以下是线上聊天内容:"开头,然后原样复制对话记录,每条一行,格式为"发言者: 内容"
- comment: "${getCupName(cupNumber)}"
【输出规则 - 必须严格遵守
1. 直接以 { 开头,以 } 结尾
2. 禁止使用markdown代码块(禁止\`\`\`
3. 禁止输出任何解释、思考、前言
4. 禁止在JSON前后添加任何文字
【JSON示例
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"}
【JSON字段说明
- "keys": 数组3-5个关键词人名、地点、事件等
- "content": 字符串,以"以下是线上聊天内容:"开头,然后逐行记录对话,格式为"发言者: 内容",用\\n分隔
- "comment": "${getCupName(cupNumber)}"
【正确输出示例】
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧","comment":"${getCupName(cupNumber)}"}
`;
}
@@ -469,13 +469,13 @@ export function generateSummaryPrompt(allChats, cupNumber) {
});
});
prompt += `\n请将以上聊天记录原样整理成${getCupName(cupNumber)}的JSON`;
prompt += `\n【立即输出JSON】请将以上聊天记录整理成${getCupName(cupNumber)}的JSON对象(直接以{开头)`;
return prompt;
}
// 调用总结API
export async function callSummaryAPI(prompt) {
export async function callSummaryAPI(prompt, cupNumber = 1) {
const settings = getSettings();
const apiUrl = settings.summaryApiUrl;
const apiKey = settings.summaryApiKey;
@@ -513,14 +513,16 @@ export async function callSummaryAPI(prompt) {
const content = data.choices?.[0]?.message?.content || '';
// 解析JSON
const parsed = parseJSONResponse(content);
const parsed = parseJSONResponse(content, cupNumber);
if (parsed) return parsed;
throw new Error('AI返回内容为空或无法解析');
}
// 解析JSON响应
function parseJSONResponse(content) {
function parseJSONResponse(content, cupNumber = 1) {
if (!content || !content.trim()) return null;
// 方法1: 直接解析
try {
const result = JSON.parse(content);
@@ -545,16 +547,26 @@ function parseJSONResponse(content) {
}
} catch (e) {}
// 降级方案
if (content && content.trim().length > 20) {
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
return {
keys: [...new Set(words)].slice(0, 5),
content: content.substring(0, 30000).replace(/```[\s\S]*?```/g, '').trim(),
comment: '感情记录'
};
}
// 方法4: 尝试修复常见的JSON格式问题
try {
// 替换中文冒号和引号
let fixed = content
.replace(//g, ':')
.replace(/"/g, '"')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
const firstBrace = fixed.indexOf('{');
const lastBrace = fixed.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
const result = JSON.parse(fixed.substring(firstBrace, lastBrace + 1));
if (result.keys && result.content) return result;
}
} catch (e) {}
// 不再使用降级方案返回null让调用者处理错误
console.error('[可乐] JSON解析失败原始内容前500字符:', content.substring(0, 500));
return null;
}
@@ -732,7 +744,7 @@ export async function executeSummary() {
// 为单个聊天生成总结
updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...');
const prompt = generateSummaryPrompt([chat], cupNumber);
const entry = await callSummaryAPI(prompt);
const entry = await callSummaryAPI(prompt, cupNumber);
// 保存到收藏
saveEntryToFavorites(entry, cupNumber, lorebookName);

105
ui.js
View File

@@ -100,11 +100,12 @@ export function getUserPersonaFromST() {
return null;
}
// 生成聊天列表 HTML包含单聊群聊)
// 生成聊天列表 HTML包含单聊、群聊和多人群聊)
export function generateChatList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const multiPersonChats = settings.multiPersonChats || [];
// 处理单聊
const contactsWithChat = contacts.map((contact, index) => {
@@ -136,8 +137,22 @@ export function generateChatList() {
};
});
// 处理多人群聊
const multiPersonWithChat = multiPersonChats.map((chat, index) => {
const chatHistory = chat.chatHistory || [];
const lastMsg = getLastRenderableMessage(chatHistory);
const lastMsgTime = lastMsg ? (lastMsg.timestamp || chat.lastMessageTime || 0) : (chat.lastMessageTime || 0);
return {
type: 'multiPerson',
...chat,
originalIndex: index,
lastMsg,
lastMsgTime: lastMsgTime || Date.now()
};
});
// 合并并排序
const allChats = [...contactsWithChat, ...groupsWithChat].sort((a, b) => b.lastMsgTime - a.lastMsgTime);
const allChats = [...contactsWithChat, ...groupsWithChat, ...multiPersonWithChat].sort((a, b) => b.lastMsgTime - a.lastMsgTime);
if (allChats.length === 0) {
return `
@@ -155,6 +170,8 @@ export function generateChatList() {
return allChats.map(chat => {
if (chat.type === 'group') {
return generateGroupChatItem(chat, settings);
} else if (chat.type === 'multiPerson') {
return generateMultiPersonChatItem(chat);
} else {
return generateContactChatItem(chat);
}
@@ -163,7 +180,9 @@ export function generateChatList() {
// 生成单聊列表项
function generateContactChatItem(contact) {
if (!contact) return '';
const lastMsg = contact.lastMsg;
if (!lastMsg) return '';
let preview = '';
if (lastMsg.type === 'voice' || lastMsg.isVoice) {
preview = '[语音]';
@@ -319,13 +338,61 @@ function generateGroupChatItem(group, settings) {
`;
}
// 生成多人群聊列表项
function generateMultiPersonChatItem(chat) {
const lastMsg = chat.lastMsg;
let preview = '';
if (lastMsg) {
const sender = lastMsg.characterName ? `[${lastMsg.characterName}]: ` : '';
if (lastMsg.isVoice) {
preview = `${sender}[语音]`;
} else if (lastMsg.isImage) {
preview = `${sender}[图片]`;
} else if (lastMsg.isSticker) {
preview = `${sender}[表情]`;
} else {
let content = lastMsg.content || '';
if (content.length > 15) content = content.substring(0, 15) + '...';
preview = `${sender}${content}`;
}
} else {
preview = '群聊已创建';
}
const msgTime = chat.lastMsgTime ? formatChatTime(chat.lastMsgTime) : '';
const memberCount = chat.members?.length || 0;
// 使用保存的头像,如果没有则显示白底黑字"群"
let avatarHtml;
if (chat.avatar) {
avatarHtml = `<img src="${chat.avatar}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px;">`;
} else {
avatarHtml = `<div style="width: 100%; height: 100%; background: #fff; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: bold; color: #000;">群</div>`;
}
return `
<div class="wechat-chat-item wechat-chat-item-mp" data-mp-id="${chat.id}" data-mp-index="${chat.originalIndex}">
<div class="wechat-chat-item-avatar" style="display: flex; align-items: center; justify-content: center;">${avatarHtml}</div>
<div class="wechat-chat-item-info">
<div class="wechat-chat-item-name">${escapeHtml(chat.name || '群聊')}(${memberCount})</div>
<div class="wechat-chat-item-preview">${escapeHtml(preview)}</div>
</div>
<div class="wechat-chat-item-meta">
<span class="wechat-chat-item-time">${msgTime}</span>
</div>
</div>
`;
}
// 生成联系人列表 HTML
export function generateContactsList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const multiPersonChats = settings.multiPersonChats || [];
if (contacts.length === 0 && groupChats.length === 0) {
if (contacts.length === 0 && groupChats.length === 0 && multiPersonChats.length === 0) {
return `
<div class="wechat-empty">
<div class="wechat-empty-icon">
@@ -403,6 +470,38 @@ export function generateContactsList() {
`;
});
// 生成多人群聊卡片
multiPersonChats.forEach((mpChat, index) => {
const memberCount = mpChat.members?.length || 0;
const chatName = mpChat.name || '群聊';
const hasCustomApi = mpChat.useCustomApi || false;
// 使用保存的头像,如果没有则显示白底黑字"群"
let avatarHtml;
if (mpChat.avatar) {
avatarHtml = `<img src="${mpChat.avatar}" alt="" style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px;">`;
} else {
avatarHtml = `<div class="wechat-card-fallback" style="display:flex; background: #fff; color: #000; font-weight: bold;">群</div>`;
}
html += `
<div class="wechat-contact-card wechat-mp-card" data-mp-index="${index}">
<div class="wechat-card-swipe-wrapper">
<div class="wechat-card-content wechat-mp-card-content" data-mp-index="${index}" title="点击开始聊天">
<div class="wechat-card-avatar wechat-mp-avatar" data-mp-index="${index}" title="点击配置API">
${avatarHtml}
${hasCustomApi ? '<div class="wechat-mp-api-badge">⚙️</div>' : ''}
</div>
<div class="wechat-card-name">${escapeHtml(chatName)}(${memberCount})</div>
</div>
<div class="wechat-card-delete wechat-mp-delete" data-mp-index="${index}">
<span>删除</span>
</div>
</div>
</div>
`;
});
// 生成联系人卡片
contacts.forEach((contact, index) => {
const firstChar = contact.name ? contact.name.charAt(0) : '?';

View File

@@ -101,6 +101,7 @@ function showIncomingCallPage() {
// 隐藏主界面元素,显示来电界面
document.getElementById('wechat-video-call-center')?.classList.add('hidden');
document.getElementById('wechat-video-call-chat')?.classList.add('hidden');
document.getElementById('wechat-video-call-input-area')?.classList.add('hidden');
document.getElementById('wechat-video-call-actions')?.classList.add('hidden');
incomingEl.classList.remove('hidden');
@@ -165,8 +166,9 @@ function showCallPage() {
timeEl.classList.add('hidden'); // 拨打中不显示计时
}
// 隐藏对话框
// 隐藏对话框和输入框
document.getElementById('wechat-video-call-chat')?.classList.add('hidden');
document.getElementById('wechat-video-call-input-area')?.classList.add('hidden');
document.getElementById('wechat-video-call-messages')?.innerHTML &&
(document.getElementById('wechat-video-call-messages').innerHTML = '');
@@ -266,8 +268,9 @@ function onVideoCallConnected() {
document.getElementById('wechat-video-call-incoming')?.classList.add('hidden');
document.getElementById('wechat-video-call-actions')?.classList.remove('hidden');
// 显示对话框
// 显示对话框和输入框
document.getElementById('wechat-video-call-chat')?.classList.remove('hidden');
document.getElementById('wechat-video-call-input-area')?.classList.remove('hidden');
// 接通后才显示计时
const timeEl = document.getElementById('wechat-video-call-time');

759
voice-api.js Normal file
View File

@@ -0,0 +1,759 @@
/**
* 语音 API 封装
* TTS (文字转语音) 和 STT (语音转文字)
*/
import { getSettings } from './config.js';
/**
* 获取语音 API 配置
* @param {Object} contact - 角色对象(可选,用于获取角色独立配置)
* @returns {Object} 配置对象
*/
export function getVoiceApiConfig(contact = null) {
const settings = getSettings();
// 基础配置
const config = {
stt: {
url: settings.sttApiUrl || '',
key: settings.sttApiKey || '',
model: settings.sttModel || ''
},
tts: {
url: settings.ttsApiUrl || '',
key: settings.ttsApiKey || '',
model: settings.ttsModel || '',
voice: settings.ttsVoice || '',
speed: settings.ttsSpeed || 1,
emotion: settings.ttsEmotion || '默认',
proxyUrl: settings.ttsProxyUrl || ''
}
};
// 角色独立 TTS 配置
if (contact?.useCustomVoice && contact.customTtsVoice) {
config.tts.voice = contact.customTtsVoice;
}
return config;
}
/**
* 根据 Blob 类型获取文件名
*/
function getAudioFileName(blob) {
const type = blob.type || 'audio/webm';
if (type.includes('webm')) return 'audio.webm';
if (type.includes('ogg')) return 'audio.ogg';
if (type.includes('mp4')) return 'audio.mp4';
if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3';
if (type.includes('wav')) return 'audio.wav';
if (type.includes('flac')) return 'audio.flac';
return 'audio.webm';
}
/**
* 将音频 Blob 转换为 WAV 格式(更好的兼容性)
* 导出供其他模块使用
*/
export async function convertToWav(audioBlob) {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// 创建 WAV 文件
const numChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
const bytesPerSample = bitDepth / 8;
const blockAlign = numChannels * bytesPerSample;
const samples = audioBuffer.length;
const dataSize = samples * blockAlign;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
// WAV 头部
const writeString = (offset, str) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeString(36, 'data');
view.setUint32(40, dataSize, true);
// 写入音频数据
const channelData = [];
for (let i = 0; i < numChannels; i++) {
channelData.push(audioBuffer.getChannelData(i));
}
let offset = 44;
for (let i = 0; i < samples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const sample = Math.max(-1, Math.min(1, channelData[ch][i]));
const intSample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
view.setInt16(offset, intSample, true);
offset += 2;
}
}
await audioContext.close();
return new Blob([buffer], { type: 'audio/wav' });
} catch (err) {
console.warn('[可乐] WAV 转换失败,使用原格式:', err);
return audioBlob;
}
}
/**
* STT: 语音转文字
* @param {Blob} audioBlob - 音频数据
* @param {Object} options - 选项
* @returns {Promise<string>} 识别的文字
*/
export async function speechToText(audioBlob, options = {}) {
const config = getVoiceApiConfig();
if (!config.stt.url || !config.stt.key) {
throw new Error('请先配置语音识别 (STT) API');
}
// 自动补全 URL 路径
let sttUrl = config.stt.url.trim().replace(/\/+$/, '');
if (!sttUrl.includes('/audio/transcriptions')) {
sttUrl = sttUrl + '/audio/transcriptions';
}
// 如果不是 WAV 格式,尝试转换以提高兼容性
let processedBlob = audioBlob;
if (!audioBlob.type.includes('wav')) {
console.log('[可乐] 转换音频为 WAV 格式...');
processedBlob = await convertToWav(audioBlob);
}
// 根据音频类型设置正确的文件名
const fileName = getAudioFileName(processedBlob);
const formData = new FormData();
formData.append('file', processedBlob, fileName);
if (config.stt.model) {
formData.append('model', config.stt.model);
}
try {
console.log('[可乐] STT 请求:', {
url: sttUrl,
model: config.stt.model,
originalType: audioBlob.type,
processedType: processedBlob.type,
audioSize: processedBlob.size,
fileName: fileName
});
const response = await fetch(sttUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.stt.key}`
},
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('[可乐] STT API 错误:', response.status, errorText);
// 尝试解析 JSON 错误
try {
const errorJson = JSON.parse(errorText);
const errorMsg = errorJson.error?.message || errorJson.message || errorText;
throw new Error(errorMsg);
} catch (parseErr) {
// 如果不是 JSON 解析错误,而是 throw 的错误,重新抛出
if (parseErr.message && !parseErr.message.includes('JSON')) {
throw parseErr;
}
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 200)}`);
}
}
const result = await response.json();
console.log('[可乐] STT 响应:', result);
return result.text || '';
} catch (err) {
console.error('[可乐] STT 请求失败:', err);
throw err;
}
}
/**
* TTS: 文字转语音
* @param {string} text - 要合成的文字
* @param {Object} contact - 角色对象(用于获取角色独立音色)
* @param {Object} options - 选项
* @returns {Promise<Blob>} 音频 Blob
*/
export async function textToSpeech(text, contact = null, options = {}) {
const config = getVoiceApiConfig(contact);
if (!config.tts.url || !config.tts.key) {
throw new Error('请先配置语音合成 (TTS) API');
}
if (!text || !text.trim()) {
throw new Error('合成文字不能为空');
}
// 自动补全 URL 路径
let ttsUrl = config.tts.url.trim().replace(/\/+$/, '');
if (!ttsUrl.includes('/audio/speech')) {
ttsUrl = ttsUrl + '/audio/speech';
}
// 构建请求体
const model = (options.model || config.tts.model || '').trim();
const voice = (options.voice || config.tts.voice || '').trim();
// 检查必填字段
if (!model) {
throw new Error('请先配置 TTS 模型');
}
if (!voice) {
throw new Error('请先配置 TTS 音色');
}
// 检测是否是 Gemini TTS 模型
const isGeminiTTS = model.toLowerCase().includes('gemini') && model.toLowerCase().includes('tts');
// 检测是否是 GSVI 模型 (gsv2p.acgnai.top)
const isGSVI = model.toLowerCase().includes('gsvi');
// 检测是否是 MiniMax TTS API
const isMiniMax = ttsUrl.toLowerCase().includes('minimax') || ttsUrl.includes('/t2a_v2');
// MiniMax API 使用完全不同的格式
if (isMiniMax) {
// 修正 URLMiniMax 使用 /v1/t2a_v2 而不是 /audio/speech
ttsUrl = ttsUrl.replace(/\/audio\/speech$/, '/t2a_v2');
if (!ttsUrl.includes('/t2a_v2')) {
ttsUrl = ttsUrl.replace(/\/+$/, '') + '/t2a_v2';
}
// 如果配置了代理 URL使用代理解决 CORS 问题)
if (config.tts.proxyUrl) {
const proxyBase = config.tts.proxyUrl.trim().replace(/\/+$/, '');
// 提取 MiniMax URL 的路径部分
const urlObj = new URL(ttsUrl);
ttsUrl = proxyBase + urlObj.pathname;
console.log('[可乐] MiniMax 使用代理:', ttsUrl);
}
}
// 构建请求体
let requestBody;
if (isMiniMax) {
// MiniMax API 格式
const speed = options.speed || config.tts.speed || 1;
const emotion = options.emotion || config.tts.emotion;
requestBody = {
model: model,
text: text.trim(),
stream: false,
voice_setting: {
voice_id: voice,
speed: speed,
vol: 1,
pitch: 0
},
audio_setting: {
sample_rate: 32000,
bitrate: 128000,
format: 'mp3',
channel: 1
}
};
// 添加情绪参数(只有有效值才添加)
if (emotion && emotion !== '默认') {
const emotionMap = {
'高兴': 'happy',
'悲伤': 'sad',
'愤怒': 'angry',
'害怕': 'fearful',
'厌恶': 'disgusted',
'惊讶': 'surprised',
'中性': 'calm',
'生动': 'fluent',
'低语': 'whisper'
};
// 只有在 emotionMap 中有对应值时才添加
const mappedEmotion = emotionMap[emotion];
if (mappedEmotion) {
requestBody.voice_setting.emotion = mappedEmotion;
}
}
} else {
requestBody = {
model: model,
voice: voice
};
// GSVI 模型只需要基本参数
if (isGSVI) {
requestBody.input = text.trim();
// GSVI API 不需要 language 和 emotion 参数
} else {
// OpenAI 标准格式使用 input
requestBody.input = text.trim();
// 非 Gemini TTS 时才添加额外参数
if (!isGeminiTTS) {
// 只有非默认语速才添加 speed 参数
const speed = options.speed || config.tts.speed || 1;
if (speed !== 1) {
requestBody.speed = speed;
}
// 扩展参数 (GPT-SoVITS 等支持)
const emotion = options.emotion || config.tts.emotion;
if (emotion && emotion !== '默认') {
requestBody.other_params = {
text_lang: '中英混合',
prompt_lang: '中文',
emotion: emotion
};
}
}
}
}
try {
const textContent = requestBody.input || requestBody.text || '';
console.log('[可乐] TTS 请求:', {
url: ttsUrl,
model: model,
voice: voice,
isGSVI: isGSVI,
isGeminiTTS: isGeminiTTS,
isMiniMax: isMiniMax,
textLength: textContent.length,
textFull: textContent // 打印完整文本
});
const response = await fetch(ttsUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': isMiniMax ? 'application/json' : 'audio/mpeg, audio/wav, audio/*',
'Authorization': `Bearer ${config.tts.key}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('[可乐] TTS API 错误:');
console.error(' 状态码:', response.status);
console.error(' 响应内容:', errorText);
console.error(' 请求URL:', ttsUrl);
console.error(' 请求体:', JSON.stringify(requestBody, null, 2));
// 尝试解析 JSON 错误
try {
const errorJson = JSON.parse(errorText);
// MiniMax 错误格式: base_resp.status_msg
const errorMsg = errorJson.base_resp?.status_msg || errorJson.error?.message || errorJson.message || errorJson.error || errorText;
throw new Error(typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg));
} catch (parseErr) {
if (parseErr.message && !parseErr.message.includes('JSON')) {
throw parseErr;
}
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 300)}`);
}
}
// MiniMax API 返回 JSON需要特殊处理
if (isMiniMax) {
const jsonResp = await response.json();
console.log('[可乐] MiniMax TTS 响应:', {
status_code: jsonResp.base_resp?.status_code,
status_msg: jsonResp.base_resp?.status_msg,
audio_length: jsonResp.extra_info?.audio_length,
audio_format: jsonResp.extra_info?.audio_format
});
// 检查 MiniMax 错误
if (jsonResp.base_resp?.status_code !== 0) {
throw new Error('MiniMax TTS 错误: ' + (jsonResp.base_resp?.status_msg || '未知错误'));
}
if (!jsonResp.data?.audio) {
throw new Error('MiniMax TTS 未返回音频数据');
}
// 将 hex 编码的音频转换为 Blob
const hexAudio = jsonResp.data.audio;
const bytes = new Uint8Array(hexAudio.length / 2);
for (let i = 0; i < hexAudio.length; i += 2) {
bytes[i / 2] = parseInt(hexAudio.substr(i, 2), 16);
}
const audioFormat = jsonResp.extra_info?.audio_format || 'mp3';
const mimeType = `audio/${audioFormat}`;
return new Blob([bytes], { type: mimeType });
}
const audioBlob = await response.blob();
console.log('[可乐] TTS 响应:', {
音频大小: audioBlob.size,
类型: audioBlob.type,
响应头ContentType: response.headers.get('content-type')
});
// 先检查是否返回了错误的 JSON有些 API 错误时返回 JSON
const contentType = response.headers.get('content-type') || audioBlob.type;
if (contentType.includes('application/json') || contentType.includes('text/')) {
const text = await audioBlob.text();
console.error('[可乐] TTS 返回了文本而非音频:', text);
try {
const errJson = JSON.parse(text);
const errMsg = errJson.error?.message || errJson.message || errJson.error || JSON.stringify(errJson);
throw new Error('TTS 错误: ' + errMsg);
} catch (e) {
if (e.message.includes('TTS')) throw e;
throw new Error('TTS 返回了非音频数据: ' + text.substring(0, 100));
}
}
// 检查是否返回了有效的音频数据
if (audioBlob.size < 100) {
console.error('[可乐] TTS 返回的数据太小,可能不是有效音频');
throw new Error('TTS 返回的音频数据无效');
}
// 修复:如果 blob 类型为空或不是音频类型,手动指定 MIME 类型
// 某些 TTS API如 GPT-SoVITS返回的音频没有正确的 Content-Type
let finalBlob = audioBlob;
if (!audioBlob.type || audioBlob.type === '' || !audioBlob.type.startsWith('audio/')) {
// 尝试从 Content-Type 头获取类型,或使用默认的 audio/wav
let mimeType = 'audio/wav';
const headerType = response.headers.get('content-type');
if (headerType && headerType.startsWith('audio/')) {
mimeType = headerType.split(';')[0].trim();
} else if (headerType && headerType.includes('octet-stream')) {
// application/octet-stream 通常是 wav 格式
mimeType = 'audio/wav';
}
console.log('[可乐] TTS blob 类型为空,手动指定为:', mimeType);
const arrayBuffer = await audioBlob.arrayBuffer();
finalBlob = new Blob([arrayBuffer], { type: mimeType });
}
return finalBlob;
} catch (err) {
console.error('[可乐] TTS 请求失败:', err);
// 检查是否是网络错误
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) {
throw new Error('网络连接失败,请检查 API 地址是否正确,或尝试使用代理');
}
throw err;
}
}
/**
* 播放音频
* @param {Blob|string} audio - 音频 Blob 或 URL
* @returns {Promise<HTMLAudioElement>} Audio 元素
*/
export function playAudio(audio) {
return new Promise((resolve, reject) => {
const audioEl = new Audio();
if (audio instanceof Blob) {
audioEl.src = URL.createObjectURL(audio);
} else {
audioEl.src = audio;
}
audioEl.onended = () => {
if (audio instanceof Blob) {
URL.revokeObjectURL(audioEl.src);
}
resolve(audioEl);
};
audioEl.onerror = (err) => {
if (audio instanceof Blob) {
URL.revokeObjectURL(audioEl.src);
}
reject(err);
};
audioEl.play().catch(reject);
});
}
/**
* 录音类
*/
export class AudioRecorder {
constructor() {
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.isRecording = false;
this.mimeType = 'audio/webm';
}
/**
* 开始录音
* @returns {Promise<void>}
*/
async start() {
if (this.isRecording) return;
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 选择最佳支持的音频格式
this.mimeType = getSupportedMimeType();
console.log('[可乐] 录音使用格式:', this.mimeType);
this.mediaRecorder = new MediaRecorder(this.stream, {
mimeType: this.mimeType
});
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
this.audioChunks.push(e.data);
}
};
this.mediaRecorder.start(100); // 每100ms收集一次数据
this.isRecording = true;
console.log('[可乐] 开始录音');
} catch (err) {
console.error('[可乐] 无法获取麦克风权限:', err);
throw new Error('无法获取麦克风权限,请检查浏览器设置');
}
}
/**
* 停止录音
* @returns {Promise<Blob>} 录音数据
*/
stop() {
return new Promise((resolve, reject) => {
if (!this.isRecording || !this.mediaRecorder) {
reject(new Error('没有正在进行的录音'));
return;
}
const mimeType = this.mimeType;
this.mediaRecorder.onstop = () => {
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
this.cleanup();
console.log('[可乐] 录音结束,格式:', mimeType, '大小:', audioBlob.size);
resolve(audioBlob);
};
this.mediaRecorder.stop();
this.isRecording = false;
});
}
/**
* 取消录音
*/
cancel() {
if (this.mediaRecorder && this.isRecording) {
this.mediaRecorder.stop();
}
this.cleanup();
this.isRecording = false;
}
/**
* 清理资源
*/
cleanup() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.mediaRecorder = null;
this.audioChunks = [];
}
/**
* 检查浏览器是否支持录音
* @returns {boolean}
*/
static isSupported() {
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const hasMediaRecorder = typeof MediaRecorder !== 'undefined';
const isSecureContext = window.isSecureContext;
console.log('[可乐] 录音支持检测:', {
getUserMedia: hasGetUserMedia,
MediaRecorder: hasMediaRecorder,
isSecureContext: isSecureContext,
protocol: location.protocol
});
return hasGetUserMedia && hasMediaRecorder;
}
/**
* 获取不支持录音的原因
* @returns {string}
*/
static getUnsupportedReason() {
if (!window.isSecureContext) {
return '需要 HTTPS 安全连接才能使用录音功能';
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return '浏览器不支持 getUserMedia API';
}
if (typeof MediaRecorder === 'undefined') {
return '浏览器不支持 MediaRecorder APIiOS Safari 需要 iOS 14.3+';
}
return '未知原因';
}
}
/**
* 获取 MediaRecorder 支持的音频格式
*/
function getSupportedMimeType() {
const types = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
'audio/mp4',
'audio/mpeg'
];
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return 'audio/webm';
}
/**
* 测试 STT API
* @returns {Promise<boolean>}
*/
export async function testSttApi() {
const config = getVoiceApiConfig();
if (!config.stt.url || !config.stt.key) {
throw new Error('请先填写 STT API 地址和密钥');
}
console.log('[可乐] 开始 STT 测试...');
console.log('[可乐] STT 配置:', {
url: config.stt.url,
model: config.stt.model,
keyLength: config.stt.key?.length || 0
});
// 创建测试音频 (1.5秒,包含一些变化的音调模拟语音)
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();
oscillator.connect(gainNode);
gainNode.connect(destination);
// 模拟语音的频率变化
oscillator.frequency.setValueAtTime(200, audioContext.currentTime);
oscillator.frequency.linearRampToValueAtTime(400, audioContext.currentTime + 0.5);
oscillator.frequency.linearRampToValueAtTime(300, audioContext.currentTime + 1);
oscillator.frequency.linearRampToValueAtTime(350, audioContext.currentTime + 1.5);
// 音量包络
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(0.5, audioContext.currentTime + 0.3);
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 1.2);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1.5);
oscillator.start();
const mimeType = getSupportedMimeType();
console.log('[可乐] 录制音频格式:', mimeType);
const recorder = new MediaRecorder(destination.stream, { mimeType });
const chunks = [];
return new Promise((resolve, reject) => {
recorder.ondataavailable = e => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
recorder.onstop = async () => {
oscillator.stop();
audioContext.close();
const blob = new Blob(chunks, { type: mimeType });
console.log('[可乐] 测试音频大小:', blob.size, 'bytes');
if (blob.size < 100) {
reject(new Error('测试音频生成失败'));
return;
}
try {
// speechToText 会自动转换为 WAV 格式
const result = await speechToText(blob);
console.log('[可乐] STT 测试结果:', result);
resolve(true);
} catch (err) {
reject(err);
}
};
recorder.start(100);
// 录制 1.5 秒
setTimeout(() => recorder.stop(), 1500);
});
}
/**
* 测试 TTS API
* @returns {Promise<Blob>}
*/
export async function testTtsApi() {
const config = getVoiceApiConfig();
if (!config.tts.url || !config.tts.key) {
throw new Error('请先填写 TTS API 地址和密钥');
}
return await textToSpeech('测试语音合成');
}

View File

@@ -106,6 +106,11 @@ function showCallPage() {
if (chatEl) {
chatEl.classList.add('hidden');
}
// 隐藏输入框
const inputAreaEl = document.getElementById('wechat-voice-call-input-area');
if (inputAreaEl) {
inputAreaEl.classList.add('hidden');
}
const messagesEl = document.getElementById('wechat-voice-call-messages');
if (messagesEl) {
messagesEl.innerHTML = '';
@@ -243,6 +248,11 @@ function onCallConnected() {
if (chatEl) {
chatEl.classList.remove('hidden');
}
// 显示输入框
const inputAreaEl = document.getElementById('wechat-voice-call-input-area');
if (inputAreaEl) {
inputAreaEl.classList.remove('hidden');
}
// 切换到通话中按钮(隐藏来电按钮,显示通话控制按钮)
const incomingActionsEl = document.getElementById('wechat-voice-call-incoming-actions');
@@ -719,7 +729,9 @@ async function triggerAIGreeting() {
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<\s*meme\s*>[^<]*<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<meme>[^<]*<\/meme>/gi, '').trim();
reply = reply.replace(/<\/?meme>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除语音标记
@@ -844,7 +856,9 @@ ${lastMessages}
for (const part of parts) {
let reply = part.trim();
// 通话中禁用表情包/图片/音乐等富媒体(兜底过滤)
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<\s*meme\s*>[^<]*<\s*\/\s*meme\s*>/gi, '').trim();
reply = reply.replace(/<meme>[^<]*<\/meme>/gi, '').trim();
reply = reply.replace(/<\/?meme>/gi, '').trim();
if (!reply) continue;
if (/^\[(?:表情|照片|分享音乐|音乐)[:]/.test(reply)) continue;
// 移除可能的特殊标记