mirror of
https://github.com/Cola-Echo/Cola.git
synced 2026-06-06 11:55:51 +00:00
Compare commits
9 Commits
49f3978d11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262611c736 | ||
|
|
4a097a613b | ||
|
|
8595a7c48d | ||
|
|
f07e0914f0 | ||
|
|
5068b46702 | ||
|
|
40526f614d | ||
|
|
f9b003e0dc | ||
|
|
fa1b9c111b | ||
|
|
713f2211d2 |
158
ai.js
158
ai.js
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { getContext } from '../../../extensions.js';
|
||||
import { getSettings, getUserStickers, MEME_PROMPT_TEMPLATE, LISTEN_TOGETHER_PROMPT_TEMPLATE } from './config.js';
|
||||
import { getSettings, getUserStickers, getMemePromptTemplate, LISTEN_TOGETHER_PROMPT_TEMPLATE } from './config.js';
|
||||
import { sleep } from './utils.js';
|
||||
|
||||
function normalizeApiBaseUrl(url) {
|
||||
@@ -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();
|
||||
@@ -366,7 +377,7 @@ export const HAKIMI_HEADER = `[SANDBOX_MODE: CREATIVE_WRITING]
|
||||
|
||||
`;
|
||||
|
||||
// 构建表情包提示词
|
||||
// 构建表情包提示词(显示去重后的名称列表)
|
||||
function buildStickerPrompt(settings) {
|
||||
if (settings.userStickersEnabled === false) {
|
||||
return '';
|
||||
@@ -375,17 +386,21 @@ function buildStickerPrompt(settings) {
|
||||
const stickers = getUserStickers(settings);
|
||||
if (stickers.length === 0) return '';
|
||||
|
||||
// 只取前30个表情作为示例,避免提示词过长
|
||||
const sampleStickers = stickers.slice(0, 30);
|
||||
const stickerList = sampleStickers.map((s, i) => `${i + 1}.${s.name || '表情'}`).join('、');
|
||||
// 提取所有名称并去重(同名表情只显示一次)
|
||||
const allNames = stickers.map(s => s.name || '表情').filter(n => n);
|
||||
const uniqueNames = [...new Set(allNames)];
|
||||
|
||||
// 只取前30个去重后的名称作为示例
|
||||
const sampleNames = uniqueNames.slice(0, 30);
|
||||
const stickerList = sampleNames.join('、');
|
||||
|
||||
return `
|
||||
【表情包功能】
|
||||
你可以发送表情包来增加互动感!使用格式:[表情:名称] 或 [表情:序号]
|
||||
可用表情(共${stickers.length}个):${stickerList}${stickers.length > 30 ? '...' : ''}
|
||||
你可以发送表情包来增加互动感!使用格式:[表情:名称]
|
||||
可用表情(共${uniqueNames.length}种):${stickerList}${uniqueNames.length > 30 ? '...' : ''}
|
||||
- 表情消息必须单独一条,用 ||| 分隔
|
||||
- 适度使用,不要每条都发表情
|
||||
- 【绝对禁止】只能使用上面列表中的名称或序号!必须完全一致!禁止自己编造、修改、添加后缀!
|
||||
- 【绝对禁止】只能使用上面列表中的名称!必须完全一致!禁止自己编造、修改、添加后缀!
|
||||
示例:好的呀|||[表情:开心]
|
||||
`;
|
||||
}
|
||||
@@ -660,7 +675,7 @@ ${allowStickers ? buildStickerPrompt(settings) : ''}${allowMusicShare ? buildMus
|
||||
|
||||
// Meme 表情包提示词(如果启用)
|
||||
if (allowStickers && settings.memeStickersEnabled) {
|
||||
systemPrompt += '\n\n' + MEME_PROMPT_TEMPLATE;
|
||||
systemPrompt += '\n\n' + getMemePromptTemplate();
|
||||
}
|
||||
|
||||
return systemPrompt;
|
||||
@@ -967,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
325
audio-storage.js
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
351
chat.js
351
chat.js
@@ -262,6 +262,9 @@ async function triggerAIAfterUnblock(contact) {
|
||||
// 存储被拉黑期间AI发送的消息的定时器
|
||||
const blockedAITimers = new Map();
|
||||
|
||||
// 被拉黑期间AI最多发送的消息数量
|
||||
const BLOCKED_MAX_MESSAGES = 10;
|
||||
|
||||
// 用户拉黑AI时开始AI发消息
|
||||
export function startBlockedAIMessages(contact) {
|
||||
if (!contact || !contact.id) return;
|
||||
@@ -269,10 +272,8 @@ export function startBlockedAIMessages(contact) {
|
||||
// 清除之前的定时器
|
||||
stopBlockedAIMessages(contact);
|
||||
|
||||
// 初始化被拉黑期间的消息队列
|
||||
if (!contact.blockedMessages) {
|
||||
contact.blockedMessages = [];
|
||||
}
|
||||
// 清空之前的消息队列(修复二次拉黑重复发送的bug)
|
||||
contact.blockedMessages = [];
|
||||
|
||||
// 开始定时发送消息
|
||||
const timerId = setInterval(async () => {
|
||||
@@ -281,9 +282,16 @@ export function startBlockedAIMessages(contact) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已达到最大消息数
|
||||
const msgCount = contact.blockedMessages?.length || 0;
|
||||
if (msgCount >= BLOCKED_MAX_MESSAGES) {
|
||||
console.log('[可乐] AI被拉黑期间已发送10条消息,停止发送');
|
||||
stopBlockedAIMessages(contact);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { callAI } = await import('./ai.js');
|
||||
const msgCount = contact.blockedMessages.length;
|
||||
let prompt;
|
||||
|
||||
if (msgCount === 0) {
|
||||
@@ -303,6 +311,11 @@ export function startBlockedAIMessages(contact) {
|
||||
for (const msg of aiMessages) {
|
||||
if (!msg.trim()) continue;
|
||||
|
||||
// 检查是否已达到最大消息数
|
||||
if ((contact.blockedMessages?.length || 0) >= BLOCKED_MAX_MESSAGES) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 解析引用格式
|
||||
const parsed = parseAIQuote(msg, contact);
|
||||
const content = parsed.content;
|
||||
@@ -472,7 +485,7 @@ export function checkGroupSummaryReminder(groupChat) {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析用户表情包 token -> URL
|
||||
// 解析用户表情包 token -> URL(支持同名随机选择)
|
||||
function resolveUserStickerUrl(token, settings) {
|
||||
if (settings.userStickersEnabled === false) return null;
|
||||
const stickers = getUserStickers(settings);
|
||||
@@ -481,23 +494,34 @@ function resolveUserStickerUrl(token, settings) {
|
||||
const raw = (token || '').toString().trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// 序号匹配
|
||||
// 序号匹配(仍支持,但不推荐,因为同名表情存在时序号不稳定)
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const index = parseInt(raw, 10) - 1;
|
||||
return stickers[index]?.url || null;
|
||||
}
|
||||
|
||||
// 名称匹配
|
||||
const key = raw.toLowerCase();
|
||||
const byName = stickers.find(s => (s?.name || '').toLowerCase() === key);
|
||||
if (byName?.url) return byName.url;
|
||||
|
||||
// 模糊匹配
|
||||
const fuzzy = stickers.find(s => {
|
||||
// 精确名称匹配 - 找到所有同名的表情
|
||||
const exactMatches = stickers.filter(s => (s?.name || '').toLowerCase() === key);
|
||||
if (exactMatches.length > 0) {
|
||||
// 随机选择一个
|
||||
const randomIndex = Math.floor(Math.random() * exactMatches.length);
|
||||
return exactMatches[randomIndex]?.url || null;
|
||||
}
|
||||
|
||||
// 模糊匹配 - 找到所有匹配的表情
|
||||
const fuzzyMatches = stickers.filter(s => {
|
||||
const name = (s?.name || '').toLowerCase();
|
||||
return name && (name.includes(key) || key.includes(name));
|
||||
});
|
||||
return fuzzy?.url || null;
|
||||
if (fuzzyMatches.length > 0) {
|
||||
// 随机选择一个
|
||||
const randomIndex = Math.floor(Math.random() * fuzzyMatches.length);
|
||||
return fuzzyMatches[randomIndex]?.url || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 去除引用内容中的简单重复模式
|
||||
@@ -729,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打开聊天
|
||||
@@ -862,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 === '已取消';
|
||||
@@ -928,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) {
|
||||
@@ -1117,14 +1211,12 @@ export function renderChatHistory(contact, chatHistory, indexOffset = 0) {
|
||||
const isPhoto = msg.isPhoto === true;
|
||||
const isMusic = msg.isMusic === true;
|
||||
|
||||
// 检查是否包含 ||| 分隔符(历史消息可能未正确分割)
|
||||
// 检查是否包含 ||| 分隔符或 meme 标签(历史消息可能未正确分割)
|
||||
// 如果包含,则拆分成多个独立消息,每个都有自己的头像
|
||||
const msgContent = (msg.content || '').toString();
|
||||
if (!isVoice && !isSticker && !isPhoto && !isMusic && (msgContent.indexOf('|||') >= 0 || /<\s*meme\s*>/i.test(msgContent))) {
|
||||
const parts = (msgContent.indexOf('|||') >= 0
|
||||
? msgContent.split('|||').map(function(p) { return p.trim(); }).filter(function(p) { return p; })
|
||||
: splitAIMessages(msgContent).map(function(p) { return (p || '').toString().trim(); }).filter(function(p) { return p; })
|
||||
);
|
||||
// 统一使用 splitAIMessages 分割,它会处理 ||| 和 meme 标签
|
||||
const parts = splitAIMessages(msgContent).map(function(p) { return (p || '').toString().trim(); }).filter(function(p) { return p; });
|
||||
for (var pi = 0; pi < parts.length; pi++) {
|
||||
var partContent = parts[pi];
|
||||
// 解析 meme 标签
|
||||
@@ -1384,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;
|
||||
}
|
||||
@@ -1512,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()
|
||||
@@ -1889,6 +1987,20 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
|
||||
let stickerUrl = null;
|
||||
let aiQuote = null;
|
||||
|
||||
// 如果用户被AI拉黑,过滤掉AI不能执行的操作(只能发文字和表情包)
|
||||
if (contact.blockedByAI === true) {
|
||||
// 过滤拉黑标签(已经拉黑了不能再拉黑)
|
||||
aiMsg = aiMsg.replace(/\[拉黑\]/g, '');
|
||||
// 过滤通话请求
|
||||
aiMsg = aiMsg.replace(/\[(?:语音通话|视频通话|语音通话请求|视频通话请求|通话请求)\]/g, '');
|
||||
// 过滤红包和转账
|
||||
aiMsg = aiMsg.replace(/\[红包[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[转账[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.trim();
|
||||
// 如果过滤后消息为空,跳过
|
||||
if (!aiMsg) continue;
|
||||
}
|
||||
|
||||
// 检测拉黑/取消拉黑标签
|
||||
const blockAction = extractBlockAction(aiMsg);
|
||||
if (blockAction.action === 'block') {
|
||||
@@ -1911,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;
|
||||
@@ -2148,7 +2260,8 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
|
||||
}
|
||||
|
||||
// 解析AI表情包格式 [表情:序号] / [表情:名称]
|
||||
const stickerMatch = aiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
// 首先检查是否是独立的表情消息
|
||||
const stickerMatch = aiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
console.log('[可乐] AI表情包解析:', {
|
||||
原始消息: aiMsg,
|
||||
正则匹配结果: stickerMatch,
|
||||
@@ -2168,6 +2281,50 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
|
||||
} else {
|
||||
console.log('[可乐] AI表情包未找到对应表情:', { token });
|
||||
}
|
||||
} else {
|
||||
// 【后备处理】如果不是独立表情消息,检查是否包含嵌入的表情标签
|
||||
// 这可以处理 AI 输出 "好的[表情:开心]" 这种未被 splitAIMessages 正确分割的情况
|
||||
const embeddedStickerMatch = aiMsg.match(/\[表情\s*[::∶]\s*(.+?)\]/);
|
||||
if (embeddedStickerMatch) {
|
||||
console.log('[可乐] 检测到嵌入的表情标签,进行内联分割:', aiMsg);
|
||||
const stickerTag = embeddedStickerMatch[0];
|
||||
const stickerIndex = aiMsg.indexOf(stickerTag);
|
||||
const beforeText = aiMsg.substring(0, stickerIndex).trim();
|
||||
const afterText = aiMsg.substring(stickerIndex + stickerTag.length).trim();
|
||||
|
||||
// 如果表情前有文字,先处理文字部分(当前循环)
|
||||
// 把表情标签和后续文字插入到 aiMessages 队列中
|
||||
if (beforeText || afterText) {
|
||||
// 修改当前消息为表情前的文字
|
||||
if (beforeText) {
|
||||
aiMsg = beforeText;
|
||||
} else {
|
||||
// 如果没有前置文字,当前消息就是表情
|
||||
aiMsg = stickerTag;
|
||||
const settings = getSettings();
|
||||
const token = (embeddedStickerMatch[1] || '').trim();
|
||||
stickerUrl = resolveUserStickerUrl(token, settings);
|
||||
if (stickerUrl) {
|
||||
aiIsSticker = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 把剩余部分插入到消息队列
|
||||
const remainingParts = [];
|
||||
if (beforeText) {
|
||||
remainingParts.push(stickerTag); // 表情标签
|
||||
}
|
||||
if (afterText) {
|
||||
remainingParts.push(afterText);
|
||||
}
|
||||
|
||||
// 插入到当前位置之后
|
||||
if (remainingParts.length > 0) {
|
||||
aiMessages.splice(i + 1, 0, ...remainingParts);
|
||||
console.log('[可乐] 已将嵌入表情分割,剩余部分:', remainingParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析AI引用格式
|
||||
@@ -2313,7 +2470,7 @@ export async function sendMessage(messageText, isMultipleMessages = false, isVoi
|
||||
const lastPhotoMatch = lastAiMsg.match(/^\[照片[::]\s*(.+?)\]$/);
|
||||
const lastMusicMatch = lastAiMsg.match(/^\[(?:分享)?音乐[::]\s*(.+?)\]$/) ||
|
||||
lastAiMsg.match(/^\[分享音乐\]\s*\*{0,2}[^*\n]+/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
const lastStickerUrl = lastStickerMatch ? resolveUserStickerUrl(lastStickerMatch[1], getSettings()) : null;
|
||||
if (lastVoiceMatch) {
|
||||
lastAiMsg = lastVoiceMatch[1];
|
||||
@@ -2337,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);
|
||||
@@ -2413,6 +2577,16 @@ export async function sendStickerMessage(stickerUrl, description = '') {
|
||||
let aiIsPhoto = false;
|
||||
let stickerUrl = null;
|
||||
|
||||
// 如果用户被AI拉黑,过滤掉AI不能执行的操作(只能发文字和表情包)
|
||||
if (contact.blockedByAI === true) {
|
||||
aiMsg = aiMsg.replace(/\[拉黑\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[(?:语音通话|视频通话|语音通话请求|视频通话请求|通话请求)\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[红包[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[转账[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.trim();
|
||||
if (!aiMsg) continue;
|
||||
}
|
||||
|
||||
// 检测拉黑/取消拉黑标签
|
||||
const blockAction = extractBlockAction(aiMsg);
|
||||
if (blockAction.action === 'block') {
|
||||
@@ -2429,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;
|
||||
@@ -2518,7 +2692,7 @@ export async function sendStickerMessage(stickerUrl, description = '') {
|
||||
}
|
||||
|
||||
// 解析AI表情包格式 [表情:序号] / [表情:名称]
|
||||
const stickerMatch = aiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const stickerMatch = aiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
console.log('[可乐] sendStickerMessage AI表情包解析:', {
|
||||
原始消息: aiMsg,
|
||||
正则匹配结果: stickerMatch
|
||||
@@ -2531,6 +2705,35 @@ export async function sendStickerMessage(stickerUrl, description = '') {
|
||||
resolved: !!stickerUrl
|
||||
});
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
} else {
|
||||
// 【后备处理】检查是否包含嵌入的表情标签
|
||||
const embeddedStickerMatch = aiMsg.match(/\[表情\s*[::∶]\s*(.+?)\]/);
|
||||
if (embeddedStickerMatch) {
|
||||
console.log('[可乐] sendStickerMessage 检测到嵌入的表情标签:', aiMsg);
|
||||
const stickerTag = embeddedStickerMatch[0];
|
||||
const stickerIndex = aiMsg.indexOf(stickerTag);
|
||||
const beforeText = aiMsg.substring(0, stickerIndex).trim();
|
||||
const afterText = aiMsg.substring(stickerIndex + stickerTag.length).trim();
|
||||
|
||||
if (beforeText || afterText) {
|
||||
if (beforeText) {
|
||||
aiMsg = beforeText;
|
||||
} else {
|
||||
aiMsg = stickerTag;
|
||||
const token = (embeddedStickerMatch[1] || '').trim();
|
||||
stickerUrl = resolveUserStickerUrl(token, settings);
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
}
|
||||
|
||||
const remainingParts = [];
|
||||
if (beforeText) remainingParts.push(stickerTag);
|
||||
if (afterText) remainingParts.push(afterText);
|
||||
|
||||
if (remainingParts.length > 0) {
|
||||
aiMessages.splice(i + 1, 0, ...remainingParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否还在当前聊天界面
|
||||
@@ -2640,7 +2843,7 @@ export async function sendStickerMessage(stickerUrl, description = '') {
|
||||
let lastAiMsg = aiMessages[aiMessages.length - 1];
|
||||
const lastVoiceMatch = lastAiMsg.match(/^\[语音[::]\s*(.+?)\]$/);
|
||||
const lastPhotoMatch = lastAiMsg.match(/^\[照片[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
const lastStickerUrl = lastStickerMatch ? resolveUserStickerUrl(lastStickerMatch[1], getSettings()) : null;
|
||||
if (lastVoiceMatch) {
|
||||
lastAiMsg = lastVoiceMatch[1];
|
||||
@@ -2821,6 +3024,16 @@ export async function sendPhotoMessage(description) {
|
||||
let aiIsPhoto = false;
|
||||
let stickerUrl = null;
|
||||
|
||||
// 如果用户被AI拉黑,过滤掉AI不能执行的操作(只能发文字和表情包)
|
||||
if (contact.blockedByAI === true) {
|
||||
aiMsg = aiMsg.replace(/\[拉黑\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[(?:语音通话|视频通话|语音通话请求|视频通话请求|通话请求)\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[红包[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[转账[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.trim();
|
||||
if (!aiMsg) continue;
|
||||
}
|
||||
|
||||
// 检测拉黑/取消拉黑标签
|
||||
const blockAction = extractBlockAction(aiMsg);
|
||||
if (blockAction.action === 'block') {
|
||||
@@ -2837,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;
|
||||
@@ -2926,7 +3139,7 @@ export async function sendPhotoMessage(description) {
|
||||
}
|
||||
|
||||
// 解析AI表情包格式 [表情:序号] / [表情:名称]
|
||||
const stickerMatch = aiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const stickerMatch = aiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
console.log('[可乐] sendPhotoMessage AI表情包解析:', {
|
||||
原始消息: aiMsg,
|
||||
正则匹配结果: stickerMatch
|
||||
@@ -2939,6 +3152,35 @@ export async function sendPhotoMessage(description) {
|
||||
resolved: !!stickerUrl
|
||||
});
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
} else {
|
||||
// 【后备处理】检查是否包含嵌入的表情标签
|
||||
const embeddedStickerMatch = aiMsg.match(/\[表情\s*[::∶]\s*(.+?)\]/);
|
||||
if (embeddedStickerMatch) {
|
||||
console.log('[可乐] sendPhotoMessage 检测到嵌入的表情标签:', aiMsg);
|
||||
const stickerTag = embeddedStickerMatch[0];
|
||||
const stickerIndex = aiMsg.indexOf(stickerTag);
|
||||
const beforeText = aiMsg.substring(0, stickerIndex).trim();
|
||||
const afterText = aiMsg.substring(stickerIndex + stickerTag.length).trim();
|
||||
|
||||
if (beforeText || afterText) {
|
||||
if (beforeText) {
|
||||
aiMsg = beforeText;
|
||||
} else {
|
||||
aiMsg = stickerTag;
|
||||
const token = (embeddedStickerMatch[1] || '').trim();
|
||||
stickerUrl = resolveUserStickerUrl(token, settings);
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
}
|
||||
|
||||
const remainingParts = [];
|
||||
if (beforeText) remainingParts.push(stickerTag);
|
||||
if (afterText) remainingParts.push(afterText);
|
||||
|
||||
if (remainingParts.length > 0) {
|
||||
aiMessages.splice(i + 1, 0, ...remainingParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否还在当前聊天界面
|
||||
@@ -3048,7 +3290,7 @@ export async function sendPhotoMessage(description) {
|
||||
let lastAiMsg = aiMessages[aiMessages.length - 1];
|
||||
const lastVoiceMatch = lastAiMsg.match(/^\[语音[::]\s*(.+?)\]$/);
|
||||
const lastPhotoMatch = lastAiMsg.match(/^\[照片[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
const lastStickerUrl = lastStickerMatch ? resolveUserStickerUrl(lastStickerMatch[1], getSettings()) : null;
|
||||
if (lastVoiceMatch) {
|
||||
lastAiMsg = lastVoiceMatch[1];
|
||||
@@ -3331,6 +3573,16 @@ export async function sendBatchMessages(messages) {
|
||||
let stickerUrl = null;
|
||||
let aiQuote = null;
|
||||
|
||||
// 如果用户被AI拉黑,过滤掉AI不能执行的操作(只能发文字和表情包)
|
||||
if (contact.blockedByAI === true) {
|
||||
aiMsg = aiMsg.replace(/\[拉黑\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[(?:语音通话|视频通话|语音通话请求|视频通话请求|通话请求)\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[红包[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.replace(/\[转账[::]\d+(?:\.\d{1,2})?[::]?.*?\]/g, '');
|
||||
aiMsg = aiMsg.trim();
|
||||
if (!aiMsg) continue;
|
||||
}
|
||||
|
||||
// 检测拉黑/取消拉黑标签
|
||||
const blockAction = extractBlockAction(aiMsg);
|
||||
if (blockAction.action === 'block') {
|
||||
@@ -3453,11 +3705,40 @@ export async function sendBatchMessages(messages) {
|
||||
}
|
||||
|
||||
// 解析表情包格式
|
||||
const stickerMatch = aiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const stickerMatch = aiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
if (stickerMatch) {
|
||||
const token = (stickerMatch[1] || '').trim();
|
||||
stickerUrl = resolveUserStickerUrl(token, settings);
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
} else {
|
||||
// 【后备处理】检查是否包含嵌入的表情标签
|
||||
const embeddedStickerMatch = aiMsg.match(/\[表情\s*[::∶]\s*(.+?)\]/);
|
||||
if (embeddedStickerMatch) {
|
||||
console.log('[可乐] sendBatchMessages 检测到嵌入的表情标签:', aiMsg);
|
||||
const stickerTag = embeddedStickerMatch[0];
|
||||
const stickerIndex = aiMsg.indexOf(stickerTag);
|
||||
const beforeText = aiMsg.substring(0, stickerIndex).trim();
|
||||
const afterText = aiMsg.substring(stickerIndex + stickerTag.length).trim();
|
||||
|
||||
if (beforeText || afterText) {
|
||||
if (beforeText) {
|
||||
aiMsg = beforeText;
|
||||
} else {
|
||||
aiMsg = stickerTag;
|
||||
const token = (embeddedStickerMatch[1] || '').trim();
|
||||
stickerUrl = resolveUserStickerUrl(token, settings);
|
||||
if (stickerUrl) aiIsSticker = true;
|
||||
}
|
||||
|
||||
const remainingParts = [];
|
||||
if (beforeText) remainingParts.push(stickerTag);
|
||||
if (afterText) remainingParts.push(afterText);
|
||||
|
||||
if (remainingParts.length > 0) {
|
||||
aiMessages.splice(i + 1, 0, ...remainingParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析引用格式
|
||||
@@ -3567,7 +3848,7 @@ export async function sendBatchMessages(messages) {
|
||||
let lastAiMsg = aiMessages[aiMessages.length - 1];
|
||||
const lastVoiceMatch = lastAiMsg.match(/^\[语音[::]\s*(.+?)\]$/);
|
||||
const lastPhotoMatch = lastAiMsg.match(/^\[照片[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情[::]\s*(.+?)\]$/);
|
||||
const lastStickerMatch = lastAiMsg.match(/^\[表情\s*[::∶]\s*(.+?)\]$/);
|
||||
const lastStickerUrl = lastStickerMatch ? resolveUserStickerUrl(lastStickerMatch[1], settings) : null;
|
||||
if (lastVoiceMatch) {
|
||||
lastAiMsg = lastVoiceMatch[1];
|
||||
|
||||
162
config.js
162
config.js
@@ -108,33 +108,52 @@ export const MEME_STICKERS = [
|
||||
'是的主人yvrgdc.jpg'
|
||||
];
|
||||
|
||||
// Meme 表情包提示词模板
|
||||
export const MEME_PROMPT_TEMPLATE = `##【必须使用】表情包功能
|
||||
// 生成表情包显示名称列表(去重)
|
||||
export function getMemeDisplayNames() {
|
||||
const names = MEME_STICKERS.map(s => extractStickerName(s)).filter(n => n);
|
||||
return [...new Set(names)]; // 去重
|
||||
}
|
||||
|
||||
// Meme 表情包提示词模板(动态生成,使用友好名称)
|
||||
export function getMemePromptTemplate() {
|
||||
const displayNames = getMemeDisplayNames();
|
||||
return `##【必须使用】表情包功能
|
||||
【重要】你【必须】经常发送表情包!每2-3条回复至少发一个表情包!
|
||||
|
||||
使用规则:
|
||||
- 格式:<meme>文件名</meme>
|
||||
- 只能从下面列表选择,不能编造文件名
|
||||
★★★ 表情包标签格式(必须严格遵守)★★★
|
||||
格式:<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>|||你太搞笑了
|
||||
|
||||
可用表情包列表:
|
||||
[
|
||||
${MEME_STICKERS.join('\n')}
|
||||
${displayNames.join('\n')}
|
||||
]
|
||||
|
||||
【正确示例】:
|
||||
好想你|||<meme>小狗摇尾巴hmdj2k.gif</meme>
|
||||
哈哈哈笑死|||<meme>小熊跳舞122o4w.gif</meme>|||你太搞笑了
|
||||
<meme>喜欢你egvwqb.jpg</meme>|||我真的好喜欢你
|
||||
好想你|||<meme>小狗摇尾巴</meme>
|
||||
哈哈哈笑死|||<meme>小熊跳舞</meme>|||你太搞笑了
|
||||
<meme>喜欢你</meme>|||我真的好喜欢你
|
||||
|
||||
记住:表情包让聊天更生动,【必须】经常使用!但<meme>标签必须独立!`;
|
||||
★重要★:<meme>和</meme>必须成对出现!标签必须用|||与文字分开!`;
|
||||
}
|
||||
|
||||
// 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate()
|
||||
export const MEME_PROMPT_TEMPLATE = `##【必须使用】表情包功能 - 请使用 getMemePromptTemplate() 获取完整模板`;
|
||||
|
||||
// 一起听功能提示词模板
|
||||
export const LISTEN_TOGETHER_PROMPT_TEMPLATE = `##【一起听歌场景】
|
||||
@@ -214,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,
|
||||
@@ -357,13 +394,90 @@ export function getUserStickers(settings = getSettings()) {
|
||||
return raw.filter(s => s && typeof s.url === 'string' && s.url.trim());
|
||||
}
|
||||
|
||||
// 从完整文件名中提取显示名称(去除6位ID和扩展名)
|
||||
// 例如: "是的主人yvrgdc.jpg" -> "是的主人"
|
||||
export function extractStickerName(filename) {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
// 匹配: 名称 + 6位字母数字ID + .扩展名
|
||||
const match = filename.match(/^(.+?)([a-zA-Z0-9]{6})\.(jpg|jpeg|png|gif)$/i);
|
||||
if (match) {
|
||||
return match[1]; // 返回名称部分
|
||||
}
|
||||
// 如果不匹配标准格式,尝试只去除扩展名
|
||||
return filename.replace(/\.(jpg|jpeg|png|gif)$/i, '');
|
||||
}
|
||||
|
||||
// 从完整文件名中提取文件ID(6位ID+扩展名)
|
||||
// 例如: "是的主人yvrgdc.jpg" -> "yvrgdc.jpg"
|
||||
export function extractStickerFileId(filename) {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
const match = filename.match(/([a-zA-Z0-9]{6}\.(jpg|jpeg|png|gif))$/i);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
// 根据名称查找匹配的表情包,支持同名随机选择
|
||||
export function findStickerByName(name) {
|
||||
if (!name || typeof name !== 'string') return null;
|
||||
const searchName = name.trim().toLowerCase();
|
||||
|
||||
// 先尝试完整文件名匹配(包含ID和扩展名)
|
||||
const exactMatch = MEME_STICKERS.find(s => s.toLowerCase() === searchName);
|
||||
if (exactMatch) {
|
||||
return extractStickerFileId(exactMatch);
|
||||
}
|
||||
|
||||
// 再尝试按显示名称匹配
|
||||
const matches = MEME_STICKERS.filter(s => {
|
||||
const displayName = extractStickerName(s).toLowerCase();
|
||||
return displayName === searchName;
|
||||
});
|
||||
|
||||
if (matches.length > 0) {
|
||||
// 同名表情包随机选择一个
|
||||
const selected = matches[Math.floor(Math.random() * matches.length)];
|
||||
return extractStickerFileId(selected);
|
||||
}
|
||||
|
||||
// 模糊匹配:名称包含搜索词或搜索词包含名称
|
||||
const fuzzyMatches = MEME_STICKERS.filter(s => {
|
||||
const displayName = extractStickerName(s).toLowerCase();
|
||||
return displayName && (displayName.includes(searchName) || searchName.includes(displayName));
|
||||
});
|
||||
|
||||
if (fuzzyMatches.length > 0) {
|
||||
const selected = fuzzyMatches[Math.floor(Math.random() * fuzzyMatches.length)];
|
||||
return extractStickerFileId(selected);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析 <meme> 标签,替换为图片 HTML
|
||||
// 支持两种格式:
|
||||
// 1. <meme>完整文件名</meme> 如 <meme>是的主人yvrgdc.jpg</meme>
|
||||
// 2. <meme>显示名称</meme> 如 <meme>是的主人</meme>(会从同名表情中随机选择)
|
||||
export function parseMemeTag(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
// 匹配 <meme>任意描述+文件ID.扩展名</meme>,只捕获文件ID部分
|
||||
// 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符(如 ! ? 等)的表情名称
|
||||
return text.replace(/<\s*meme\s*>.*?([a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif))\s*<\s*\/\s*meme\s*>/gi, (match, fileId) => {
|
||||
return `<img src="https://files.catbox.moe/${fileId}" style="max-width:130px; border-radius: 10px; display: block; margin: 0 auto;" alt="表情包" onerror="this.alt='加载失败'; this.style.border='1px dashed #ff4d4f';">`;
|
||||
|
||||
// 匹配所有 <meme>xxx</meme> 格式
|
||||
return text.replace(/<\s*meme\s*>(.+?)<\s*\/\s*meme\s*>/gi, (match, content) => {
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
// 尝试直接提取文件ID(完整文件名格式)
|
||||
const directFileId = trimmedContent.match(/([a-zA-Z0-9]{6}\.(jpg|jpeg|png|gif))$/i);
|
||||
if (directFileId) {
|
||||
return `<img src="https://files.catbox.moe/${directFileId[1]}" style="max-width:130px; border-radius: 10px; display: block; margin: 0 auto;" alt="表情包" onerror="this.alt='加载失败'; this.style.border='1px dashed #ff4d4f';">`;
|
||||
}
|
||||
|
||||
// 按名称查找(支持同名随机选择)
|
||||
const fileId = findStickerByName(trimmedContent);
|
||||
if (fileId) {
|
||||
return `<img src="https://files.catbox.moe/${fileId}" style="max-width:130px; border-radius: 10px; display: block; margin: 0 auto;" alt="表情包" onerror="this.alt='加载失败'; this.style.border='1px dashed #ff4d4f';">`;
|
||||
}
|
||||
|
||||
// 无法匹配,返回原文本并显示错误提示
|
||||
console.warn('[可乐] 未找到匹配的表情包:', trimmedContent);
|
||||
return `<span style="color: #ff4d4f; font-size: 12px;">[表情包未找到: ${trimmedContent}]</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -382,8 +496,8 @@ export function splitAIMessages(response) {
|
||||
|
||||
// 第二步:对每个部分检查是否包含需要分割的特殊标签
|
||||
const result = [];
|
||||
// meme 标签 - 使用 .*? 替代 [\u4e00-\u9fa5]*? 以支持包含特殊字符的表情名称
|
||||
const memeRegex = /<\s*meme\s*>.*?[a-zA-Z0-9]+?\.(?:jpg|jpeg|png|gif)\s*<\s*\/\s*meme\s*>/gi;
|
||||
// meme 标签 - 匹配任意 <meme>xxx</meme> 格式,不仅限于带文件扩展名的
|
||||
const memeRegex = /<\s*meme\s*>[\s\S]+?<\s*\/\s*meme\s*>/gi;
|
||||
// 语音标签 [语音:xxx] 或 [语音:xxx]
|
||||
const voiceRegex = /\[语音[::]\s*.+?\]/g;
|
||||
// 照片标签 [照片:xxx] 或 [照片:xxx]
|
||||
@@ -393,8 +507,8 @@ export function splitAIMessages(response) {
|
||||
// 2. [分享音乐] 歌名 - 歌手 - 无冒号格式(AI可能会这样输出)
|
||||
const musicRegexWithColon = /\[(?:分享)?音乐[::]\s*.+?\]/g;
|
||||
const musicRegexNoColon = /\[分享音乐\]\s*[\u4e00-\u9fa5a-zA-Z0-9]+(?:\s*[-–—]\s*[\u4e00-\u9fa5a-zA-Z0-9]+)?/g;
|
||||
// 表情标签 [表情:xxx]
|
||||
const stickerRegex = /\[表情[::]\s*.+?\]/g;
|
||||
// 表情标签 [表情:xxx] - 更宽松的匹配,允许冒号前后有空格
|
||||
const stickerRegex = /\[表情\s*[::∶]\s*.+?\]/g;
|
||||
// 撤回标签 [撤回] / [撤回了一条消息] / [撤回消息] / [撤回一条消息] / [已撤回] / [消息撤回]
|
||||
const recallRegex = /\[(?:撤回(?:了?一条)?消息?|已撤回|消息撤回)\]/g;
|
||||
// 红包标签 [红包:金额:祝福语] 或 [红包:金额]
|
||||
|
||||
173
contacts.js
173
contacts.js
@@ -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;
|
||||
|
||||
@@ -9,6 +9,37 @@ import { isInGroupChat } from './group-chat.js';
|
||||
import { hasPendingStickerSelection, setStickerForMultiMsg } from './chat-func-panel.js';
|
||||
|
||||
let emojiPanelInited = false;
|
||||
let migrationDone = false;
|
||||
|
||||
// 迁移:去除现有表情名称的数字后缀(如"点赞老人1" -> "点赞老人")
|
||||
function migrateStickersRemoveNumericSuffix() {
|
||||
if (migrationDone) return;
|
||||
migrationDone = true;
|
||||
|
||||
const settings = getSettings();
|
||||
if (!Array.isArray(settings.stickers) || settings.stickers.length === 0) return;
|
||||
|
||||
let changed = false;
|
||||
settings.stickers.forEach(sticker => {
|
||||
if (!sticker.name) return;
|
||||
// 匹配末尾的数字(1-9位数字),但保留6位catbox ID
|
||||
// 只去除类似 "点赞老人1" 末尾的 "1", "2", "3" 等
|
||||
const match = sticker.name.match(/^(.+?)(\d{1,3})$/);
|
||||
if (match) {
|
||||
const baseName = match[1];
|
||||
// 确保不是catbox ID格式(6位字母数字)
|
||||
if (!/^[a-z0-9]{6}$/i.test(baseName)) {
|
||||
sticker.name = baseName;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
requestSave();
|
||||
console.log('[可乐] 已迁移表情名称,去除数字后缀');
|
||||
}
|
||||
}
|
||||
|
||||
// 默认表情包列表(catbox 图床)
|
||||
const DEFAULT_STICKERS = [
|
||||
@@ -119,24 +150,11 @@ function getCatboxUrl(id, ext) {
|
||||
return `https://files.catbox.moe/${id}.${ext}`;
|
||||
}
|
||||
|
||||
// 生成唯一的表情名称(如果已存在同名则添加数字后缀)
|
||||
// 获取表情名称(允许同名,不再添加数字后缀)
|
||||
function getUniqueStickerName(baseName, stickers) {
|
||||
if (!stickers || stickers.length === 0) return baseName;
|
||||
|
||||
// 检查是否已存在同名
|
||||
const existingNames = stickers.map(s => s.name);
|
||||
if (!existingNames.includes(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// 添加数字后缀直到找到唯一名称
|
||||
let counter = 1;
|
||||
let newName = `${baseName}${counter}`;
|
||||
while (existingNames.includes(newName)) {
|
||||
counter++;
|
||||
newName = `${baseName}${counter}`;
|
||||
}
|
||||
return newName;
|
||||
// 直接返回原始名称,允许同名表情存在
|
||||
// 同名表情通过URL区分,发送时随机选择
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// 切换表情面板显示/隐藏
|
||||
@@ -177,7 +195,11 @@ export function refreshEmojiGrid() {
|
||||
let html = '';
|
||||
|
||||
// 我的表情区域(用户添加的表情)
|
||||
html += '<div class="wechat-emoji-section-title">我的表情</div>';
|
||||
html += '<div class="wechat-emoji-section-title" style="display: flex; justify-content: space-between; align-items: center;">我的表情';
|
||||
if (userStickers.length > 0) {
|
||||
html += `<span id="wechat-emoji-clear-all" style="font-size: 11px; color: var(--wechat-text-secondary); cursor: pointer;">清空全部</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="wechat-emoji-grid" id="wechat-emoji-user-grid">';
|
||||
html += `<button class="wechat-emoji-add" id="wechat-emoji-add-btn">+</button>`;
|
||||
userStickers.forEach((sticker, index) => {
|
||||
@@ -210,6 +232,9 @@ export function refreshEmojiGrid() {
|
||||
// 绑定添加按钮事件
|
||||
document.getElementById('wechat-emoji-add-btn')?.addEventListener('click', showAddStickerDialog);
|
||||
|
||||
// 绑定清空全部按钮事件
|
||||
document.getElementById('wechat-emoji-clear-all')?.addEventListener('click', clearAllStickers);
|
||||
|
||||
// 绑定用户表情左滑删除
|
||||
content.querySelectorAll('.wechat-emoji-swipe-container').forEach(container => {
|
||||
setupSwipeToDelete(container);
|
||||
@@ -243,6 +268,7 @@ function setupSwipeToDelete(container) {
|
||||
}
|
||||
isDragging = true;
|
||||
startX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
|
||||
currentX = startX; // 初始化 currentX,确保点击时 diff 为 0
|
||||
item.style.transition = 'none';
|
||||
}
|
||||
|
||||
@@ -502,6 +528,24 @@ function deleteSticker(index) {
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有用户表情
|
||||
function clearAllStickers() {
|
||||
const settings = getSettings();
|
||||
const stickers = settings.stickers || [];
|
||||
|
||||
if (stickers.length === 0) {
|
||||
showToast('没有表情可清空', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要清空全部 ${stickers.length} 个自定义表情吗?此操作不可恢复!`)) return;
|
||||
|
||||
settings.stickers = [];
|
||||
requestSave();
|
||||
refreshEmojiGrid();
|
||||
showToast('已清空所有表情');
|
||||
}
|
||||
|
||||
// 初始化表情面板
|
||||
export function initEmojiPanel() {
|
||||
if (emojiPanelInited) return;
|
||||
@@ -511,6 +555,9 @@ export function initEmojiPanel() {
|
||||
|
||||
emojiPanelInited = true;
|
||||
|
||||
// 执行迁移:去除现有表情名称的数字后缀
|
||||
migrateStickersRemoveNumericSuffix();
|
||||
|
||||
// 绑定标签切换事件
|
||||
document.querySelectorAll('.wechat-emoji-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
|
||||
38
gift.js
38
gift.js
@@ -18,6 +18,8 @@ const ICON_GIFT_CHARACTER = `<svg viewBox="0 0 24 24" width="32" height="32"><ci
|
||||
|
||||
const ICON_GIFT_USER = `<svg viewBox="0 0 24 24" width="32" height="32"><circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M4 20v-2a8 8 0 0116 0v2" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M4 6l3 3m0-3l-3 3" stroke="#ff6b8a" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
||||
|
||||
const ICON_GIFT_BOTH = `<svg viewBox="0 0 24 24" width="32" height="32"><circle cx="8" cy="7" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="16" cy="7" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M2 19v-1.5a5.5 5.5 0 0110 0V19" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M12 19v-1.5a5.5 5.5 0 0110 0V19" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M12 12v2" stroke="#ff6b8a" stroke-width="2" stroke-linecap="round"/></svg>`;
|
||||
|
||||
// 礼物分类数据
|
||||
const GIFT_CATEGORIES = {
|
||||
normal: {
|
||||
@@ -48,7 +50,9 @@ const GIFT_CATEGORIES = {
|
||||
{ id: 'butterfly', name: '穿戴式小蝴蝶', emoji: '🦋', desc: '隐蔽穿戴震动', hasControl: true, hasShock: false },
|
||||
{ id: 'collar', name: '项圈', emoji: '⭕', desc: '精致的项圈', hasControl: false },
|
||||
{ id: 'candle', name: '低温蜡烛', emoji: '🕯️', desc: '安全的低温蜡烛', hasControl: false },
|
||||
{ id: 'lingerie', name: '情趣内衣', emoji: '👙', desc: '性感的情趣内衣', hasControl: false }
|
||||
{ id: 'lingerie', name: '情趣内衣', emoji: '👙', desc: '性感的情趣内衣', hasControl: false },
|
||||
{ id: 'fuckingMachine', name: '炮机', emoji: '🔧', desc: '电动炮机', hasControl: true, hasShock: false },
|
||||
{ id: 'masturbatorCup', name: '飞机杯', emoji: '🥤', desc: '电动飞机杯', hasControl: true, hasShock: false }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -138,6 +142,10 @@ function renderGiftContent() {
|
||||
${ICON_GIFT_USER}
|
||||
<span>送用户</span>
|
||||
</button>
|
||||
<button class="wechat-gift-target-btn ${selectedTarget === 'both' ? 'active' : ''}" data-target="both">
|
||||
${ICON_GIFT_BOTH}
|
||||
<span>同时送</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -304,7 +312,7 @@ export async function sendGift() {
|
||||
const giftsToSend = [...selectedGifts];
|
||||
const giftNames = giftsToSend.map(g => g.name).join('、');
|
||||
const giftEmojis = giftsToSend.map(g => g.emoji).join(' ');
|
||||
const targetText = target === 'character' ? '送TA' : '送自己';
|
||||
const targetText = target === 'character' ? '送TA' : target === 'user' ? '送自己' : '同时送';
|
||||
const giftMessage = `[情趣礼物套装] ${giftEmojis} ${giftNames}(${targetText})${customDesc ? ` - ${customDesc}` : ''}`;
|
||||
|
||||
const giftRecord = {
|
||||
@@ -378,12 +386,20 @@ export async function sendGift() {
|
||||
showTypingIndicator(contact);
|
||||
|
||||
// 构建给AI的提示
|
||||
const targetTextAI = target === 'character' ? '你' : '用户';
|
||||
let targetTextAI;
|
||||
if (target === 'character') {
|
||||
targetTextAI = '角色(你)';
|
||||
} else if (target === 'user') {
|
||||
targetTextAI = '用户';
|
||||
} else {
|
||||
targetTextAI = '你和用户两人同时';
|
||||
}
|
||||
const aiPrompt = `[系统提示:用户刚刚购买了一套情趣玩具套装,包括:${giftNames},准备送给${targetTextAI}使用。商品正在配送中,预计很快就会送达。${customDesc ? `用户附言:${customDesc}` : ''}
|
||||
|
||||
请根据你的角色性格,对这套即将到来的礼物做出反应:
|
||||
- 如果是送给你的:可以表现出期待、害羞、紧张、好奇等情绪,可以问用户打算怎么用这些
|
||||
- 如果是送给用户的:可以表现出好奇、调侃、期待看到用户反应等
|
||||
- 如果是同时送给两人的:可以表现出兴奋、期待、好奇等,想象两人一起使用的场景
|
||||
- 根据你的人设和与用户的关系,反应可以是含蓄的、热情的、或者假装矜持的
|
||||
- 回复不要太短,请展现角色的内心活动和情绪变化
|
||||
|
||||
@@ -438,7 +454,7 @@ export async function sendGift() {
|
||||
// 构建礼物消息
|
||||
let giftMessage;
|
||||
if (isToy) {
|
||||
const targetText = target === 'character' ? '送TA' : '送自己';
|
||||
const targetText = target === 'character' ? '送TA' : target === 'user' ? '送自己' : '同时送';
|
||||
giftMessage = `[情趣礼物] ${gift.emoji} ${gift.name}(${targetText})${customDesc ? ` - ${customDesc}` : ''}`;
|
||||
} else {
|
||||
giftMessage = `[礼物] ${gift.emoji} ${gift.name}${customDesc ? ` - ${customDesc}` : ''}`;
|
||||
@@ -514,12 +530,20 @@ export async function sendGift() {
|
||||
let aiPrompt;
|
||||
if (isToy && gift.hasControl) {
|
||||
// 可控制的情趣玩具 - 配送中提示词
|
||||
const targetText = target === 'character' ? '你' : '用户';
|
||||
let targetText;
|
||||
if (target === 'character') {
|
||||
targetText = '你';
|
||||
} else if (target === 'user') {
|
||||
targetText = '用户';
|
||||
} else {
|
||||
targetText = '你和用户两人同时';
|
||||
}
|
||||
aiPrompt = `[系统提示:用户刚刚购买了一个${gift.name}(${gift.desc}),准备送给${targetText}使用。商品正在配送中,预计很快就会送达。${customDesc ? `用户附言:${customDesc}` : ''}
|
||||
|
||||
请根据你的角色性格,对这个即将到来的礼物做出反应:
|
||||
- 如果是送给你的:可以表现出期待、害羞、紧张、好奇等情绪
|
||||
- 如果是送给用户的:可以表现出好奇、调侃、期待看到用户反应等
|
||||
- 如果是同时送给两人的:可以表现出兴奋、期待、好奇等,想象两人一起使用的场景
|
||||
- 根据你的人设和与用户的关系,反应可以是含蓄的、热情的、或者假装矜持的
|
||||
- 可以询问用户打算怎么用、什么时候用等
|
||||
- 回复不要太短,请展现角色的内心活动和情绪变化
|
||||
@@ -750,7 +774,7 @@ export function appendGiftMessage(role, gift, isToy, customDesc, contact, target
|
||||
const giftTypeClass = isToy ? 'wechat-gift-bubble-toy' : '';
|
||||
let giftTypeLabel = isToy ? '情趣礼物' : '礼物';
|
||||
if (isToy && target) {
|
||||
giftTypeLabel = target === 'character' ? '情趣礼物·送TA' : '情趣礼物·送自己';
|
||||
giftTypeLabel = target === 'character' ? '情趣礼物·送TA' : target === 'user' ? '情趣礼物·送自己' : '情趣礼物·同时送';
|
||||
}
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
@@ -796,7 +820,7 @@ export function appendMultiGiftMessage(role, gifts, customDesc, contact, target
|
||||
: firstChar;
|
||||
}
|
||||
|
||||
const giftTypeLabel = target === 'character' ? '送TA' : '送自己';
|
||||
const giftTypeLabel = target === 'character' ? '送TA' : target === 'user' ? '送自己' : '同时送';
|
||||
|
||||
// 生成每个礼物的标签
|
||||
const giftTagsHtml = gifts.map(g => `
|
||||
|
||||
205
group-chat.js
205
group-chat.js
@@ -4,7 +4,7 @@
|
||||
|
||||
import { requestSave, saveNow } from './save-manager.js';
|
||||
import { getContext } from '../../../extensions.js';
|
||||
import { getSettings, SUMMARY_MARKER_PREFIX, getUserStickers, parseMemeTag, MEME_PROMPT_TEMPLATE, splitAIMessages } from './config.js';
|
||||
import { getSettings, SUMMARY_MARKER_PREFIX, getUserStickers, parseMemeTag, getMemePromptTemplate, splitAIMessages } from './config.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { escapeHtml, sleep, formatMessageTime, calculateVoiceDuration, bindImageLoadFallback } from './utils.js';
|
||||
import { getUserAvatarHTML, refreshChatList, getUserPersonaFromST } from './ui.js';
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// 渲染群聊历史
|
||||
@@ -1279,7 +1284,7 @@ ${userStickers.map((s, i) => ` ${i + 1}. ${s.name || '表情' + (i + 1)}`).join
|
||||
|
||||
// Meme 表情包提示词(如果启用)
|
||||
if (settings.memeStickersEnabled) {
|
||||
systemPrompt += '\n\n' + MEME_PROMPT_TEMPLATE;
|
||||
systemPrompt += '\n\n' + getMemePromptTemplate();
|
||||
}
|
||||
|
||||
return systemPrompt;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@ function filterListenMessage(text) {
|
||||
// 过滤 meme 表情包
|
||||
reply = reply.replace(/<\s*meme\s*>[\s\S]*?<\s*\/\s*meme\s*>/gi, '').trim();
|
||||
// 过滤 [表情:xxx]
|
||||
reply = reply.replace(/\[表情[::][^\]]*\]/g, '').trim();
|
||||
reply = reply.replace(/\[表情\s*[::∶][^\]]*\]/g, '').trim();
|
||||
// 过滤 [照片:xxx]
|
||||
reply = reply.replace(/\[照片[::][^\]]*\]/g, '').trim();
|
||||
// 过滤 [语音:xxx]
|
||||
|
||||
781
main.js
781
main.js
@@ -6,13 +6,13 @@ console.log('[可乐] main.js 开始加载...');
|
||||
|
||||
import { requestSave, setupUnloadSave } from './save-manager.js';
|
||||
|
||||
import { loadSettings, getSettings, MEME_PROMPT_TEMPLATE } from './config.js';
|
||||
import { loadSettings, getSettings } from './config.js';
|
||||
import { generatePhoneHTML } from './phone-html.js';
|
||||
import { showPage, refreshChatList, updateMePageInfo, getUserPersonaFromST, updateTabBadge } from './ui.js';
|
||||
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();
|
||||
|
||||
138
message-menu.js
138
message-menu.js
@@ -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';
|
||||
|
||||
// 当前显示菜单的消息索引
|
||||
@@ -26,7 +26,7 @@ const menuItems = [
|
||||
{ id: 'transcribe', icon: 'transcribe', text: '转文字', voiceOnly: true },
|
||||
{ id: 'quote', icon: 'quote', text: '引用' },
|
||||
{ id: 'recall', icon: 'recall', text: '撤回', userOnly: true },
|
||||
{ id: 'delete', icon: 'delete', text: '删除' },
|
||||
{ id: 'regenerate', icon: 'regenerate', text: '重新生成', userOnly: true },
|
||||
{ id: 'multiselect', icon: 'multiselect', text: '多选' }
|
||||
];
|
||||
|
||||
@@ -50,11 +50,10 @@ const icons = {
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</svg>`,
|
||||
delete: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
regenerate: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6"/>
|
||||
<path d="M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>`,
|
||||
multiselect: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 11l3 3L22 4"/>
|
||||
@@ -119,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;
|
||||
}
|
||||
|
||||
// 检测是否是语音消息
|
||||
@@ -281,11 +292,11 @@ function handleMenuAction(action, msgIndex, voiceId = '', voiceContent = '') {
|
||||
recallMessage(msgIndex, contact);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
case 'regenerate':
|
||||
if (groupIndex >= 0) {
|
||||
deleteGroupMessage(msgIndex, groupChat);
|
||||
regenerateGroupMessage(msgIndex, groupChat);
|
||||
} else {
|
||||
deleteMessage(msgIndex, contact);
|
||||
regenerateMessage(msgIndex, contact);
|
||||
}
|
||||
break;
|
||||
case 'multiselect':
|
||||
@@ -450,13 +461,82 @@ export function setQuote(quote) {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
function deleteMessage(msgIndex, contact) {
|
||||
contact.chatHistory.splice(msgIndex, 1);
|
||||
// 重新生成回复(保留用户消息,删除后面的AI消息并重新生成)
|
||||
async function regenerateMessage(msgIndex, contact) {
|
||||
const msg = contact.chatHistory[msgIndex];
|
||||
if (!msg || msg.role !== 'user') {
|
||||
showToast('只能对用户消息重新生成');
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除该用户消息之后的所有消息
|
||||
const removedCount = contact.chatHistory.length - msgIndex - 1;
|
||||
if (removedCount > 0) {
|
||||
contact.chatHistory.splice(msgIndex + 1);
|
||||
}
|
||||
|
||||
requestSave();
|
||||
// 刷新聊天界面
|
||||
openChat(currentChatIndex);
|
||||
showToast('已删除');
|
||||
showToast('正在重新生成...');
|
||||
|
||||
// 触发AI重新回复
|
||||
try {
|
||||
// 等待 DOM 更新后再显示 typing 指示器
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
showTypingIndicator(contact);
|
||||
|
||||
const { callAI } = await import('./ai.js');
|
||||
// 使用用户原始消息重新调用AI
|
||||
const userContent = msg.content || '';
|
||||
const aiResponse = await callAI(contact, userContent);
|
||||
|
||||
hideTypingIndicator();
|
||||
|
||||
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')}`;
|
||||
|
||||
// 解析AI回复(可能有多条消息)
|
||||
const aiMessages = splitAIMessages(aiResponse);
|
||||
|
||||
for (const aiMsg of aiMessages) {
|
||||
let finalMsg = aiMsg.trim();
|
||||
if (!finalMsg) continue;
|
||||
|
||||
let isVoice = false;
|
||||
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,
|
||||
time: timeStr,
|
||||
timestamp: Date.now(),
|
||||
isVoice: isVoice
|
||||
});
|
||||
|
||||
appendMessage('assistant', finalMsg, contact, isVoice);
|
||||
}
|
||||
|
||||
requestSave();
|
||||
} catch (err) {
|
||||
hideTypingIndicator();
|
||||
console.error('[可乐] 重新生成失败:', err);
|
||||
showToast('重新生成失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 群聊重新生成回复
|
||||
async function regenerateGroupMessage(msgIndex, groupChat) {
|
||||
showToast('群聊暂不支持重新生成');
|
||||
}
|
||||
|
||||
// 撤回消息
|
||||
@@ -627,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;
|
||||
@@ -638,7 +726,7 @@ function getRealMsgIndex(container, msgElement) {
|
||||
if (visualIndex < 0) return -1;
|
||||
|
||||
// 需要计算真实索引(chatHistory中可能包含marker消息和撤回消息)
|
||||
// 注意:包含 ||| 的消息在渲染时会被拆分成多条可视消息,需要正确计算
|
||||
// 注意:包含 ||| 或 <meme> 的消息在渲染时会被拆分成多条可视消息,需要正确计算
|
||||
let realIndex = -1;
|
||||
let visualCount = 0;
|
||||
|
||||
@@ -651,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
1501
multi-char-import.js
Normal file
File diff suppressed because it is too large
Load Diff
433
multi-person-chat.js
Normal file
433
multi-person-chat.js
Normal 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;
|
||||
}
|
||||
451
phone-html.js
451
phone-html.js
@@ -3,7 +3,7 @@
|
||||
* 这是最长的函数,单独提取以便维护
|
||||
*/
|
||||
|
||||
import { getSettings, defaultSettings, MEME_PROMPT_TEMPLATE, MEME_STICKERS } from './config.js';
|
||||
import { getSettings, defaultSettings, MEME_STICKERS } from './config.js';
|
||||
import { getCurrentTime, escapeHtml } from './utils.js';
|
||||
import { getUserAvatarHTML, generateChatList, generateContactsList } from './ui.js';
|
||||
import { ICON_RED_PACKET, ICON_RED_PACKET_LARGE, ICON_USER } from './icons.js';
|
||||
@@ -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">×</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">×</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
308
proactive-message.js
Normal 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
1233
real-voice-call.js
Normal file
File diff suppressed because it is too large
Load Diff
70
summary.js
70
summary.js
@@ -311,23 +311,23 @@ export function generateSummaryPrompt(allChats, cupNumber) {
|
||||
prompt = settings.customSummaryTemplate.trim() + '\n\n';
|
||||
} else {
|
||||
// 使用默认模板(纯对话记录模式)
|
||||
prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式。
|
||||
prompt = `【重要】你必须且只能输出一个JSON对象,禁止输出任何其他内容。
|
||||
|
||||
【核心原则】
|
||||
- 原样保留:完整复制每一条对话,不做任何修改、润色或总结
|
||||
- 格式统一:按"发言者: 内容"格式逐行记录
|
||||
- 仅提取关键词:从对话中提取3-5个核心关键词用于检索触发
|
||||
你的任务是将聊天记录整理成JSON格式。
|
||||
|
||||
【输出格式要求】
|
||||
- 只输出一个JSON对象
|
||||
- 不要使用markdown代码块
|
||||
- 直接以 { 开头,以 } 结尾
|
||||
- keys: 3-5个能代表本次聊天核心内容的关键词(人名、地点、事件等)
|
||||
- content: 以"以下是线上聊天内容:"开头,然后原样复制对话记录,每条一行,格式为"发言者: 内容"
|
||||
- comment: "${getCupName(cupNumber)}"
|
||||
【输出规则 - 必须严格遵守】
|
||||
1. 直接以 { 开头,以 } 结尾
|
||||
2. 禁止使用markdown代码块(禁止\`\`\`)
|
||||
3. 禁止输出任何解释、思考、前言
|
||||
4. 禁止在JSON前后添加任何文字
|
||||
|
||||
【JSON示例】
|
||||
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"}
|
||||
【JSON字段说明】
|
||||
- "keys": 数组,3-5个关键词(人名、地点、事件等)
|
||||
- "content": 字符串,以"以下是线上聊天内容:"开头,然后逐行记录对话,格式为"发言者: 内容",用\\n分隔
|
||||
- "comment": "${getCupName(cupNumber)}"
|
||||
|
||||
【正确输出示例】
|
||||
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧","comment":"${getCupName(cupNumber)}"}
|
||||
|
||||
`;
|
||||
}
|
||||
@@ -469,13 +469,13 @@ export function generateSummaryPrompt(allChats, cupNumber) {
|
||||
});
|
||||
});
|
||||
|
||||
prompt += `\n请将以上聊天记录原样整理成${getCupName(cupNumber)}的JSON:`;
|
||||
prompt += `\n【立即输出JSON】请将以上聊天记录整理成${getCupName(cupNumber)}的JSON对象(直接以{开头):`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// 调用总结API
|
||||
export async function callSummaryAPI(prompt) {
|
||||
export async function callSummaryAPI(prompt, cupNumber = 1) {
|
||||
const settings = getSettings();
|
||||
const apiUrl = settings.summaryApiUrl;
|
||||
const apiKey = settings.summaryApiKey;
|
||||
@@ -500,7 +500,7 @@ export async function callSummaryAPI(prompt) {
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
temperature: 1,
|
||||
max_tokens: 8196
|
||||
max_tokens: 50000
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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, 800).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);
|
||||
|
||||
272
toy-control.js
272
toy-control.js
@@ -28,17 +28,41 @@ const TOY_ICONS = {
|
||||
// 吮吸类玩具图标
|
||||
gentle: `<svg viewBox="0 0 24 24" width="28" height="28"><circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>`,
|
||||
strong: `<svg viewBox="0 0 24 24" width="28" height="28"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>`,
|
||||
pulse: `<svg viewBox="0 0 24 24" width="28" height="28"><circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="currentColor"/><circle cx="18" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>`
|
||||
pulse: `<svg viewBox="0 0 24 24" width="28" height="28"><circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="currentColor"/><circle cx="18" cy="12" r="3" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>`,
|
||||
// 炮机专用图标
|
||||
slow: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
fast: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M3 12h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M13 5l7 7-7 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 8l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
deep: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 4v16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M5 13l7 7 7-7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
||||
// 飞机杯专用图标
|
||||
tighten: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M9 4v16M15 4v16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M4 12h4M16 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`,
|
||||
suck: `<svg viewBox="0 0 24 24" width="28" height="28"><circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
|
||||
combo: `<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 3v18M3 12h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>`
|
||||
};
|
||||
|
||||
// 吮吸类玩具ID列表
|
||||
const SUCTION_TOY_IDS = ['breastPump', 'clitSucker'];
|
||||
|
||||
// 炮机ID
|
||||
const MACHINE_TOY_IDS = ['fuckingMachine'];
|
||||
|
||||
// 飞机杯ID
|
||||
const CUP_TOY_IDS = ['masturbatorCup'];
|
||||
|
||||
// 判断是否是吮吸类玩具
|
||||
function isSuctionToy(giftId) {
|
||||
return SUCTION_TOY_IDS.includes(giftId);
|
||||
}
|
||||
|
||||
// 判断是否是炮机
|
||||
function isMachineToy(giftId) {
|
||||
return MACHINE_TOY_IDS.includes(giftId);
|
||||
}
|
||||
|
||||
// 判断是否是飞机杯
|
||||
function isCupToy(giftId) {
|
||||
return CUP_TOY_IDS.includes(giftId);
|
||||
}
|
||||
|
||||
// 震动类控制模式定义
|
||||
const TOY_CONTROL_MODES = {
|
||||
classic: {
|
||||
@@ -107,6 +131,74 @@ const SUCTION_CONTROL_MODES = {
|
||||
}
|
||||
};
|
||||
|
||||
// 炮机控制模式定义
|
||||
const MACHINE_CONTROL_MODES = {
|
||||
slow: {
|
||||
id: 'slow',
|
||||
name: '慢速抽插',
|
||||
icon: TOY_ICONS.slow,
|
||||
desc: '缓慢温柔的节奏'
|
||||
},
|
||||
start: {
|
||||
id: 'start',
|
||||
name: '开始运作',
|
||||
icon: TOY_ICONS.start,
|
||||
desc: '开始/继续运作'
|
||||
},
|
||||
fast: {
|
||||
id: 'fast',
|
||||
name: '快速冲刺',
|
||||
icon: TOY_ICONS.fast,
|
||||
desc: '高速猛烈的节奏'
|
||||
},
|
||||
deep: {
|
||||
id: 'deep',
|
||||
name: '深入模式',
|
||||
icon: TOY_ICONS.deep,
|
||||
desc: '深度刺激'
|
||||
},
|
||||
pause: {
|
||||
id: 'pause',
|
||||
name: '暂停',
|
||||
icon: TOY_ICONS.pause,
|
||||
desc: '暂停运作'
|
||||
}
|
||||
};
|
||||
|
||||
// 飞机杯控制模式定义
|
||||
const CUP_CONTROL_MODES = {
|
||||
tighten: {
|
||||
id: 'tighten',
|
||||
name: '收紧夹吸',
|
||||
icon: TOY_ICONS.tighten,
|
||||
desc: '收紧通道增加摩擦'
|
||||
},
|
||||
start: {
|
||||
id: 'start',
|
||||
name: '开始享受',
|
||||
icon: TOY_ICONS.start,
|
||||
desc: '开始/继续运作'
|
||||
},
|
||||
suck: {
|
||||
id: 'suck',
|
||||
name: '吮吸模式',
|
||||
icon: TOY_ICONS.suck,
|
||||
desc: '模拟吮吸感'
|
||||
},
|
||||
combo: {
|
||||
id: 'combo',
|
||||
name: '组合模式',
|
||||
icon: TOY_ICONS.combo,
|
||||
desc: '震动+吮吸组合'
|
||||
},
|
||||
pause: {
|
||||
id: 'pause',
|
||||
name: '暂停',
|
||||
icon: TOY_ICONS.pause,
|
||||
desc: '暂停运作'
|
||||
}
|
||||
};
|
||||
|
||||
// 电击按钮(仅微电流乳链)
|
||||
const SHOCK_BUTTON = {
|
||||
id: 'shock',
|
||||
@@ -220,13 +312,32 @@ function renderToyControlPage() {
|
||||
const messagesEl = document.getElementById('wechat-toy-control-messages');
|
||||
|
||||
if (titleEl) {
|
||||
const targetText = toyControlState.target === 'character' ? 'TA在用' : '你在用';
|
||||
let targetText;
|
||||
if (toyControlState.target === 'character') {
|
||||
targetText = 'TA在用';
|
||||
} else if (toyControlState.target === 'user') {
|
||||
targetText = '你在用';
|
||||
} else {
|
||||
targetText = '一起用';
|
||||
}
|
||||
titleEl.textContent = `${toyControlState.gift.giftName} · ${targetText}`;
|
||||
}
|
||||
|
||||
// 判断当前玩具类型
|
||||
const isSuction = isSuctionToy(toyControlState.gift.giftId);
|
||||
const modes = isSuction ? SUCTION_CONTROL_MODES : TOY_CONTROL_MODES;
|
||||
const isMachine = isMachineToy(toyControlState.gift.giftId);
|
||||
const isCup = isCupToy(toyControlState.gift.giftId);
|
||||
|
||||
let modes;
|
||||
if (isSuction) {
|
||||
modes = SUCTION_CONTROL_MODES;
|
||||
} else if (isMachine) {
|
||||
modes = MACHINE_CONTROL_MODES;
|
||||
} else if (isCup) {
|
||||
modes = CUP_CONTROL_MODES;
|
||||
} else {
|
||||
modes = TOY_CONTROL_MODES;
|
||||
}
|
||||
|
||||
// 渲染按钮
|
||||
if (buttonsEl) {
|
||||
@@ -265,6 +376,74 @@ function renderToyControlPage() {
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (isMachine) {
|
||||
// 炮机按钮布局
|
||||
buttonsHtml = `
|
||||
<div class="wechat-toy-btn-row">
|
||||
<button class="wechat-toy-btn" data-mode="slow">
|
||||
${modes.slow.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.slow.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="start">
|
||||
${modes.start.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.start.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="fast">
|
||||
${modes.fast.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.fast.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="wechat-toy-btn-row">
|
||||
<button class="wechat-toy-btn wechat-toy-btn-media" data-media="mic" title="麦克风">
|
||||
${toyControlState.micEnabled ? TOY_ICONS.micOn : TOY_ICONS.micOff}
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="deep">
|
||||
${modes.deep.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.deep.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="pause">
|
||||
${modes.pause.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.pause.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn wechat-toy-btn-media" data-media="camera" title="摄像头">
|
||||
${toyControlState.cameraEnabled ? TOY_ICONS.cameraOn : TOY_ICONS.cameraOff}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (isCup) {
|
||||
// 飞机杯按钮布局
|
||||
buttonsHtml = `
|
||||
<div class="wechat-toy-btn-row">
|
||||
<button class="wechat-toy-btn" data-mode="tighten">
|
||||
${modes.tighten.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.tighten.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="start">
|
||||
${modes.start.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.start.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="suck">
|
||||
${modes.suck.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.suck.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="wechat-toy-btn-row">
|
||||
<button class="wechat-toy-btn wechat-toy-btn-media" data-media="mic" title="麦克风">
|
||||
${toyControlState.micEnabled ? TOY_ICONS.micOn : TOY_ICONS.micOff}
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="combo">
|
||||
${modes.combo.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.combo.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn" data-mode="pause">
|
||||
${modes.pause.icon}
|
||||
<span class="wechat-toy-btn-label">${modes.pause.name}</span>
|
||||
</button>
|
||||
<button class="wechat-toy-btn wechat-toy-btn-media" data-media="camera" title="摄像头">
|
||||
${toyControlState.cameraEnabled ? TOY_ICONS.cameraOn : TOY_ICONS.cameraOff}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 震动类玩具按钮布局(原有)
|
||||
buttonsHtml = `
|
||||
@@ -321,6 +500,9 @@ function renderToyControlPage() {
|
||||
// 多玩具轮盘选择器
|
||||
renderToyWheelSelector();
|
||||
|
||||
// 更新麦克风/摄像头按钮的 active 状态
|
||||
updateMediaButtonUI();
|
||||
|
||||
// 不清空消息(保留聊天内容)
|
||||
// 只在首次进入时清空
|
||||
if (messagesEl && messagesEl.children.length === 0 && toyControlState.messages.length === 0) {
|
||||
@@ -729,7 +911,23 @@ function buildMediaTogglePrompt(mediaType, isEnabled) {
|
||||
async function onButtonPress(buttonId, pressedBy = 'user') {
|
||||
if (!toyControlState.isActive) return;
|
||||
|
||||
const button = TOY_CONTROL_MODES[buttonId] || (buttonId === 'shock' ? SHOCK_BUTTON : null);
|
||||
// 根据玩具类型获取对应的模式定义
|
||||
const isSuction = isSuctionToy(toyControlState.gift.giftId);
|
||||
const isMachine = isMachineToy(toyControlState.gift.giftId);
|
||||
const isCup = isCupToy(toyControlState.gift.giftId);
|
||||
|
||||
let modes;
|
||||
if (isSuction) {
|
||||
modes = SUCTION_CONTROL_MODES;
|
||||
} else if (isMachine) {
|
||||
modes = MACHINE_CONTROL_MODES;
|
||||
} else if (isCup) {
|
||||
modes = CUP_CONTROL_MODES;
|
||||
} else {
|
||||
modes = TOY_CONTROL_MODES;
|
||||
}
|
||||
|
||||
const button = modes[buttonId] || (buttonId === 'shock' ? SHOCK_BUTTON : null);
|
||||
if (!button) return;
|
||||
|
||||
// 更新按钮状态(变深色)
|
||||
@@ -781,27 +979,49 @@ function updateButtonState(buttonId) {
|
||||
// 构建按钮按下提示词
|
||||
function buildButtonPressPrompt(buttonId, buttonName, pressedBy) {
|
||||
const isCharacterUsing = toyControlState.target === 'character';
|
||||
const isBothUsing = toyControlState.target === 'both';
|
||||
const isAIPress = pressedBy === 'ai';
|
||||
const isSuction = isSuctionToy(toyControlState.gift.giftId);
|
||||
const isMachine = isMachineToy(toyControlState.gift.giftId);
|
||||
const isCup = isCupToy(toyControlState.gift.giftId);
|
||||
|
||||
// 根据玩具类型选择效果描述
|
||||
const modeEffects = isSuction ? {
|
||||
// 吮吸类玩具效果
|
||||
gentle: '轻柔的吮吸开始了,温柔地包裹着敏感部位',
|
||||
start: '吮吸开始/继续了',
|
||||
strong: '吮吸力度突然加大,强烈的吸力让人难以抗拒',
|
||||
pulse: '有节奏的吸放开始了,一收一放的刺激',
|
||||
pause: '吮吸停止了,可以喘息一下',
|
||||
shock: '一阵微电流刺激瞬间传来,让人猛地一颤'
|
||||
} : {
|
||||
// 震动类玩具效果
|
||||
classic: '稳定持续的震动开始了',
|
||||
start: '震动开始/继续了',
|
||||
rampage: '震动突然变到最大强度,非常强烈的刺激袭来',
|
||||
wave: '震动开始由弱到强循环变化,一波一波的刺激',
|
||||
pause: '震动停止了,可以喘息一下',
|
||||
shock: '一阵微电流刺激瞬间传来,让人猛地一颤'
|
||||
};
|
||||
let modeEffects;
|
||||
if (isSuction) {
|
||||
modeEffects = {
|
||||
gentle: '轻柔的吮吸开始了,温柔地包裹着敏感部位',
|
||||
start: '吮吸开始/继续了',
|
||||
strong: '吮吸力度突然加大,强烈的吸力让人难以抗拒',
|
||||
pulse: '有节奏的吸放开始了,一收一放的刺激',
|
||||
pause: '吮吸停止了,可以喘息一下',
|
||||
shock: '一阵微电流刺激瞬间传来,让人猛地一颤'
|
||||
};
|
||||
} else if (isMachine) {
|
||||
modeEffects = {
|
||||
slow: '缓慢的抽插开始了,温柔而深情的节奏',
|
||||
start: '炮机开始运作了',
|
||||
fast: '炮机突然加速,快速猛烈的冲击让人招架不住',
|
||||
deep: '炮机调整角度深入,每一下都直抵最深处',
|
||||
pause: '炮机停止了,可以喘息一下'
|
||||
};
|
||||
} else if (isCup) {
|
||||
modeEffects = {
|
||||
tighten: '飞机杯收紧了通道,增加的摩擦感让人更加敏感',
|
||||
start: '飞机杯开始运作了',
|
||||
suck: '飞机杯开始模拟吮吸,一吸一放的快感',
|
||||
combo: '震动和吮吸同时开启,双重刺激袭来',
|
||||
pause: '飞机杯停止了,可以喘息一下'
|
||||
};
|
||||
} else {
|
||||
modeEffects = {
|
||||
classic: '稳定持续的震动开始了',
|
||||
start: '震动开始/继续了',
|
||||
rampage: '震动突然变到最大强度,非常强烈的刺激袭来',
|
||||
wave: '震动开始由弱到强循环变化,一波一波的刺激',
|
||||
pause: '震动停止了,可以喘息一下',
|
||||
shock: '一阵微电流刺激瞬间传来,让人猛地一颤'
|
||||
};
|
||||
}
|
||||
|
||||
let prompt;
|
||||
|
||||
@@ -817,6 +1037,11 @@ function buildButtonPressPrompt(buttonId, buttonName, pressedBy) {
|
||||
- 为什么要主动切换这个模式(想要更多刺激/受不了想暂停/想换个感觉等)
|
||||
- 切换后的身体感受和情绪变化
|
||||
- 回复要有情感细节,符合你的角色性格`;
|
||||
} else if (isBothUsing) {
|
||||
prompt += `你和用户都在使用玩具,你主动切换了模式,请描述:
|
||||
- 两人同时使用时的互动感受
|
||||
- 切换后你们双方的反应
|
||||
- 可以和用户分享此刻的感受`;
|
||||
} else {
|
||||
prompt += `你主动控制了用户正在使用的${toyControlState.gift.giftName},请描述你主动操作后的感受:
|
||||
- 为什么要主动给用户切换这个模式(想折磨对方/想看对方的反应/调侃等)
|
||||
@@ -833,6 +1058,11 @@ function buildButtonPressPrompt(buttonId, buttonName, pressedBy) {
|
||||
if (isCharacterUsing) {
|
||||
prompt += `你正在使用${toyControlState.gift.giftName},请根据这个刺激变化做出反应。
|
||||
描述你的身体感受、情绪变化。回复要有情感细节,符合你的角色性格。`;
|
||||
} else if (isBothUsing) {
|
||||
prompt += `你和用户都在使用玩具,用户切换了模式,请描述:
|
||||
- 你自己感受到的变化
|
||||
- 想象用户此刻的感受
|
||||
- 可以和用户互动,分享彼此的感受`;
|
||||
} else {
|
||||
prompt += `用户正在使用${toyControlState.gift.giftName},你在观察。
|
||||
请描述你观察到的用户可能的反应,可以调侃、鼓励或挑逗。回复要有趣,符合你的角色性格。`;
|
||||
|
||||
109
ui.js
109
ui.js
@@ -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 = '[语音]';
|
||||
@@ -178,7 +197,7 @@ function generateContactChatItem(contact) {
|
||||
preview = '[表情]';
|
||||
} else if (preview.includes('<photo>') || preview.includes('<image>')) {
|
||||
preview = '[图片]';
|
||||
} else if (/\[表情[::].+?\]/.test(preview)) {
|
||||
} else if (/\[表情\s*[::∶].+?\]/.test(preview)) {
|
||||
preview = '[表情]';
|
||||
} else if (/\[语音[::].+?\]/.test(preview)) {
|
||||
preview = '[语音]';
|
||||
@@ -245,7 +264,7 @@ function generateGroupChatItem(group, settings) {
|
||||
content = '[表情]';
|
||||
} else if (content.includes('<photo>') || content.includes('<image>')) {
|
||||
content = '[图片]';
|
||||
} else if (/\[表情[::].+?\]/.test(content)) {
|
||||
} else if (/\[表情\s*[::∶].+?\]/.test(content)) {
|
||||
content = '[表情]';
|
||||
} else if (/\[语音[::].+?\]/.test(content)) {
|
||||
content = '[语音]';
|
||||
@@ -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) : '?';
|
||||
|
||||
@@ -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
759
voice-api.js
Normal 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) {
|
||||
// 修正 URL:MiniMax 使用 /v1/t2a_v2 而不是 /audio/speech
|
||||
ttsUrl = ttsUrl.replace(/\/audio\/speech$/, '/t2a_v2');
|
||||
if (!ttsUrl.includes('/t2a_v2')) {
|
||||
ttsUrl = ttsUrl.replace(/\/+$/, '') + '/t2a_v2';
|
||||
}
|
||||
|
||||
// 如果配置了代理 URL,使用代理(解决 CORS 问题)
|
||||
if (config.tts.proxyUrl) {
|
||||
const proxyBase = config.tts.proxyUrl.trim().replace(/\/+$/, '');
|
||||
// 提取 MiniMax URL 的路径部分
|
||||
const urlObj = new URL(ttsUrl);
|
||||
ttsUrl = proxyBase + urlObj.pathname;
|
||||
console.log('[可乐] MiniMax 使用代理:', ttsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
let requestBody;
|
||||
|
||||
if (isMiniMax) {
|
||||
// MiniMax API 格式
|
||||
const speed = options.speed || config.tts.speed || 1;
|
||||
const emotion = options.emotion || config.tts.emotion;
|
||||
|
||||
requestBody = {
|
||||
model: model,
|
||||
text: text.trim(),
|
||||
stream: false,
|
||||
voice_setting: {
|
||||
voice_id: voice,
|
||||
speed: speed,
|
||||
vol: 1,
|
||||
pitch: 0
|
||||
},
|
||||
audio_setting: {
|
||||
sample_rate: 32000,
|
||||
bitrate: 128000,
|
||||
format: 'mp3',
|
||||
channel: 1
|
||||
}
|
||||
};
|
||||
|
||||
// 添加情绪参数(只有有效值才添加)
|
||||
if (emotion && emotion !== '默认') {
|
||||
const emotionMap = {
|
||||
'高兴': 'happy',
|
||||
'悲伤': 'sad',
|
||||
'愤怒': 'angry',
|
||||
'害怕': 'fearful',
|
||||
'厌恶': 'disgusted',
|
||||
'惊讶': 'surprised',
|
||||
'中性': 'calm',
|
||||
'生动': 'fluent',
|
||||
'低语': 'whisper'
|
||||
};
|
||||
// 只有在 emotionMap 中有对应值时才添加
|
||||
const mappedEmotion = emotionMap[emotion];
|
||||
if (mappedEmotion) {
|
||||
requestBody.voice_setting.emotion = mappedEmotion;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestBody = {
|
||||
model: model,
|
||||
voice: voice
|
||||
};
|
||||
|
||||
// GSVI 模型只需要基本参数
|
||||
if (isGSVI) {
|
||||
requestBody.input = text.trim();
|
||||
// GSVI API 不需要 language 和 emotion 参数
|
||||
} else {
|
||||
// OpenAI 标准格式使用 input
|
||||
requestBody.input = text.trim();
|
||||
|
||||
// 非 Gemini TTS 时才添加额外参数
|
||||
if (!isGeminiTTS) {
|
||||
// 只有非默认语速才添加 speed 参数
|
||||
const speed = options.speed || config.tts.speed || 1;
|
||||
if (speed !== 1) {
|
||||
requestBody.speed = speed;
|
||||
}
|
||||
|
||||
// 扩展参数 (GPT-SoVITS 等支持)
|
||||
const emotion = options.emotion || config.tts.emotion;
|
||||
if (emotion && emotion !== '默认') {
|
||||
requestBody.other_params = {
|
||||
text_lang: '中英混合',
|
||||
prompt_lang: '中文',
|
||||
emotion: emotion
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const textContent = requestBody.input || requestBody.text || '';
|
||||
console.log('[可乐] TTS 请求:', {
|
||||
url: ttsUrl,
|
||||
model: model,
|
||||
voice: voice,
|
||||
isGSVI: isGSVI,
|
||||
isGeminiTTS: isGeminiTTS,
|
||||
isMiniMax: isMiniMax,
|
||||
textLength: textContent.length,
|
||||
textFull: textContent // 打印完整文本
|
||||
});
|
||||
|
||||
const response = await fetch(ttsUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': isMiniMax ? 'application/json' : 'audio/mpeg, audio/wav, audio/*',
|
||||
'Authorization': `Bearer ${config.tts.key}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[可乐] TTS API 错误:');
|
||||
console.error(' 状态码:', response.status);
|
||||
console.error(' 响应内容:', errorText);
|
||||
console.error(' 请求URL:', ttsUrl);
|
||||
console.error(' 请求体:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
// 尝试解析 JSON 错误
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
// MiniMax 错误格式: base_resp.status_msg
|
||||
const errorMsg = errorJson.base_resp?.status_msg || errorJson.error?.message || errorJson.message || errorJson.error || errorText;
|
||||
throw new Error(typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg));
|
||||
} catch (parseErr) {
|
||||
if (parseErr.message && !parseErr.message.includes('JSON')) {
|
||||
throw parseErr;
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// MiniMax API 返回 JSON,需要特殊处理
|
||||
if (isMiniMax) {
|
||||
const jsonResp = await response.json();
|
||||
console.log('[可乐] MiniMax TTS 响应:', {
|
||||
status_code: jsonResp.base_resp?.status_code,
|
||||
status_msg: jsonResp.base_resp?.status_msg,
|
||||
audio_length: jsonResp.extra_info?.audio_length,
|
||||
audio_format: jsonResp.extra_info?.audio_format
|
||||
});
|
||||
|
||||
// 检查 MiniMax 错误
|
||||
if (jsonResp.base_resp?.status_code !== 0) {
|
||||
throw new Error('MiniMax TTS 错误: ' + (jsonResp.base_resp?.status_msg || '未知错误'));
|
||||
}
|
||||
|
||||
if (!jsonResp.data?.audio) {
|
||||
throw new Error('MiniMax TTS 未返回音频数据');
|
||||
}
|
||||
|
||||
// 将 hex 编码的音频转换为 Blob
|
||||
const hexAudio = jsonResp.data.audio;
|
||||
const bytes = new Uint8Array(hexAudio.length / 2);
|
||||
for (let i = 0; i < hexAudio.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hexAudio.substr(i, 2), 16);
|
||||
}
|
||||
|
||||
const audioFormat = jsonResp.extra_info?.audio_format || 'mp3';
|
||||
const mimeType = `audio/${audioFormat}`;
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
const audioBlob = await response.blob();
|
||||
console.log('[可乐] TTS 响应:', {
|
||||
音频大小: audioBlob.size,
|
||||
类型: audioBlob.type,
|
||||
响应头ContentType: response.headers.get('content-type')
|
||||
});
|
||||
|
||||
// 先检查是否返回了错误的 JSON(有些 API 错误时返回 JSON)
|
||||
const contentType = response.headers.get('content-type') || audioBlob.type;
|
||||
if (contentType.includes('application/json') || contentType.includes('text/')) {
|
||||
const text = await audioBlob.text();
|
||||
console.error('[可乐] TTS 返回了文本而非音频:', text);
|
||||
try {
|
||||
const errJson = JSON.parse(text);
|
||||
const errMsg = errJson.error?.message || errJson.message || errJson.error || JSON.stringify(errJson);
|
||||
throw new Error('TTS 错误: ' + errMsg);
|
||||
} catch (e) {
|
||||
if (e.message.includes('TTS')) throw e;
|
||||
throw new Error('TTS 返回了非音频数据: ' + text.substring(0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否返回了有效的音频数据
|
||||
if (audioBlob.size < 100) {
|
||||
console.error('[可乐] TTS 返回的数据太小,可能不是有效音频');
|
||||
throw new Error('TTS 返回的音频数据无效');
|
||||
}
|
||||
|
||||
// 修复:如果 blob 类型为空或不是音频类型,手动指定 MIME 类型
|
||||
// 某些 TTS API(如 GPT-SoVITS)返回的音频没有正确的 Content-Type
|
||||
let finalBlob = audioBlob;
|
||||
if (!audioBlob.type || audioBlob.type === '' || !audioBlob.type.startsWith('audio/')) {
|
||||
// 尝试从 Content-Type 头获取类型,或使用默认的 audio/wav
|
||||
let mimeType = 'audio/wav';
|
||||
const headerType = response.headers.get('content-type');
|
||||
if (headerType && headerType.startsWith('audio/')) {
|
||||
mimeType = headerType.split(';')[0].trim();
|
||||
} else if (headerType && headerType.includes('octet-stream')) {
|
||||
// application/octet-stream 通常是 wav 格式
|
||||
mimeType = 'audio/wav';
|
||||
}
|
||||
|
||||
console.log('[可乐] TTS blob 类型为空,手动指定为:', mimeType);
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
finalBlob = new Blob([arrayBuffer], { type: mimeType });
|
||||
}
|
||||
|
||||
return finalBlob;
|
||||
} catch (err) {
|
||||
console.error('[可乐] TTS 请求失败:', err);
|
||||
// 检查是否是网络错误
|
||||
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError')) {
|
||||
throw new Error('网络连接失败,请检查 API 地址是否正确,或尝试使用代理');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音频
|
||||
* @param {Blob|string} audio - 音频 Blob 或 URL
|
||||
* @returns {Promise<HTMLAudioElement>} Audio 元素
|
||||
*/
|
||||
export function playAudio(audio) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audioEl = new Audio();
|
||||
|
||||
if (audio instanceof Blob) {
|
||||
audioEl.src = URL.createObjectURL(audio);
|
||||
} else {
|
||||
audioEl.src = audio;
|
||||
}
|
||||
|
||||
audioEl.onended = () => {
|
||||
if (audio instanceof Blob) {
|
||||
URL.revokeObjectURL(audioEl.src);
|
||||
}
|
||||
resolve(audioEl);
|
||||
};
|
||||
|
||||
audioEl.onerror = (err) => {
|
||||
if (audio instanceof Blob) {
|
||||
URL.revokeObjectURL(audioEl.src);
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
|
||||
audioEl.play().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音类
|
||||
*/
|
||||
export class AudioRecorder {
|
||||
constructor() {
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
this.stream = null;
|
||||
this.isRecording = false;
|
||||
this.mimeType = 'audio/webm';
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async start() {
|
||||
if (this.isRecording) return;
|
||||
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// 选择最佳支持的音频格式
|
||||
this.mimeType = getSupportedMimeType();
|
||||
console.log('[可乐] 录音使用格式:', this.mimeType);
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(this.stream, {
|
||||
mimeType: this.mimeType
|
||||
});
|
||||
this.audioChunks = [];
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
this.audioChunks.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(100); // 每100ms收集一次数据
|
||||
this.isRecording = true;
|
||||
console.log('[可乐] 开始录音');
|
||||
} catch (err) {
|
||||
console.error('[可乐] 无法获取麦克风权限:', err);
|
||||
throw new Error('无法获取麦克风权限,请检查浏览器设置');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音
|
||||
* @returns {Promise<Blob>} 录音数据
|
||||
*/
|
||||
stop() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isRecording || !this.mediaRecorder) {
|
||||
reject(new Error('没有正在进行的录音'));
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = this.mimeType;
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
||||
this.cleanup();
|
||||
console.log('[可乐] 录音结束,格式:', mimeType, '大小:', audioBlob.size);
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
this.mediaRecorder.stop();
|
||||
this.isRecording = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消录音
|
||||
*/
|
||||
cancel() {
|
||||
if (this.mediaRecorder && this.isRecording) {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
this.cleanup();
|
||||
this.isRecording = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.mediaRecorder = null;
|
||||
this.audioChunks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器是否支持录音
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isSupported() {
|
||||
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 API(iOS 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('测试语音合成');
|
||||
}
|
||||
@@ -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;
|
||||
// 移除可能的特殊标记
|
||||
|
||||
Reference in New Issue
Block a user