/**
* 配置、常量、默认设置
*/
import { extension_settings } from '../../../extensions.js';
// 插件名称
export const extensionName = 'wechat-simulator';
// Meme 表情包列表(catbox.moe)
export const MEME_STICKERS = [
'告到小狗法庭iaordo.jpg',
'小猫伸爪f6nqiq.gif',
'谢谢宝贝我现在那里好硬862o48.jpg',
'阿弥陀佛9cwm60.jpg',
'你好美你长得像我爱人hmpkra.jpg',
'我老实了i3ws7s.jpg',
'蹭蹭你贴贴你1of415.gif',
'喜欢你egvwqb.jpg',
'我在哭t343od.jpg',
'不干活就没饭吃2qnrgh.jpg',
'擦眼泪9gno7e.jpg',
'小狗摇尾巴hmdj2k.gif',
'爱你舔舔你ola7gd.jpg',
'不高兴x6lv1t.jpg',
'大哭3ox1j2.gif',
'你是我老婆8nn1lj.jpg',
'我是你的小狗gnna86.gif',
'我忍ftwaba.jpg',
'别难为狗了gopu17.jpg',
'我会勃起qyyd9g.jpg',
'拘谨扭捏2vejqs.jpg',
'揉揉你qqkv1z.gif',
'狗狗舔小猫vj1714.gif',
'你是我的sj7yzn.jpg',
'要亲亲吗不许拒绝umvaji.jpg',
'震惊害怕muc86m.jpg',
'丑猫哭哭4ybcj1.jpg',
'要哭了tnilep.jpg',
'我来咯r9cix2.gif',
'脑袋空空rbx0ch.jpg',
'跟着你lu2t54.png',
'小熊跳舞122o4w.gif',
'狗鼻子拱拱你kip4fo.gif',
'超级心虚k3xk40.jpg',
'我害怕我走了newaoh.jpg',
'目移69jgvg.jpg',
'上钩了cormmk.jpg',
'无语了我哭了0awxky.jpg',
'你嫌我丢人8d71mm.jpg',
'笑不出来xkop14.jpg',
'别欺负小狗啊u4t3t3.jpg',
'他妈的真是被看扁了ime5rz.jpg',
'现在强烈地想做爱oqh283.jpg',
'我操klwqm3.jpg',
'这样伤害我不太好吧zihvph.jpg',
'反正我就是变态qgha72.jpg',
'鸡巴梆硬去趟厕所pbxrqh.jpg',
'我哭了你暴力我up99xo.jpg',
'被骂饱了vpixr4.jpg',
'裤裆掏玫瑰l7q8yz.gif',
'傻瓜sbgrcu.jpg',
'咬人5hmtd1.jpg',
'哽咽z38xrc.jpg',
'欸我操了q0fv4d.jpg',
'扭捏9pon3x.jpeg',
'失望eug1e6.jpeg',
'狂犬病发作xb3naz.jpg',
'我是狗吗ma9azs.jpg',
'一笑了之9llb46.jpg',
'装可怜lcglz1.jpg',
'小狗撒欢6j6y6a.gif',
'狗舔舔esw5e2.gif',
'皱眉nibd87.gif',
'大哭auylzr.jpg',
'我要草你5neozi.jpg',
'沉默无言mzyapz.jpg',
'痛哭v4g8v6.jpg',
'擦汗dig3ks.png',
'情欲难抑h1gfp6.jpg',
'扭头不看r8rbzh.jpg',
'神色凄惶wfhp45.jpg',
'哽咽0cmn6h.jpg',
'忍眼泪td0cz7.gif',
'小期待小惊喜335fzr.gif',
'饿了w0cx8k.jpg',
'弱智兔头6svelp.jpg',
'被逮捕了uzeywu.jpg',
'看呆mqnepo.jpg',
'我的理性在远去t9e065.jpg',
'偷亲一口1jgvb1.gif',
'震惊v5n2ve.jpg',
'爷怒了49r80k.jpg',
'愤怒伤心e7lr3s.jpg',
'狗叫usjdrr.jpg',
'小狗面露难色5bk38l.jpg',
'我投降jkeps1.jpg',
'忍耐中8mnszb.jpg',
'心虚讨好mxtaj7.jpg',
'亲你的手nls3gm.jpg',
'收到ldqwqr.jpg',
'你太可爱我喜欢你ubhai8.jpg',
'惊吓tp9uvd.jpg',
'脸红星星眼dsfs7o.jpg',
'被揍了哭哭81x5zq.jpg',
'嘬嘬fg5gx3.jpg',
'超大声哭哭186h5v.jpg',
'是的主人yvrgdc.jpg'
];
// 生成表情包显示名称列表(去重)
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条回复至少发一个表情包!
使用规则:
- 格式:表情名称
- 只需要填写表情名称,不需要填写文件ID和扩展名
- 只能从下面列表选择,不能编造名称
【绝对禁止 - 最重要的规则!】
标签前后【绝对不能】有任何其他文字!必须用 ||| 分隔!
× 错误:好想你xxx ← 绝对禁止!标签和文字混在一起!
× 错误:xxx哈哈 ← 绝对禁止!标签后面有文字!
× 错误:我很开心xxx你呢 ← 绝对禁止!标签夹在文字中间!
√ 正确:好想你|||xxx ← 用|||分开,标签独立!
√ 正确:xxx|||哈哈哈 ← 标签独立一条!
可用表情包列表:
[
${displayNames.join('\n')}
]
【正确示例】:
好想你|||小狗摇尾巴
哈哈哈笑死|||小熊跳舞|||你太搞笑了
喜欢你|||我真的好喜欢你
记住:表情包让聊天更生动,【必须】经常使用!但标签必须独立!`;
}
// 保留旧变量名以兼容,但实际使用时应调用 getMemePromptTemplate()
export const MEME_PROMPT_TEMPLATE = `##【必须使用】表情包功能 - 请使用 getMemePromptTemplate() 获取完整模板`;
// 一起听功能提示词模板
export const LISTEN_TOGETHER_PROMPT_TEMPLATE = `##【一起听歌场景】
你正在和用户一起听歌,用你自己的方式自然地聊天。
当前播放歌曲:{{song_name}} - {{song_artist}}
【核心要求 - 必须遵守】
1. 只能发送纯文字消息,像朋友之间真实聊天一样
2. 保持你的性格特点,用符合你角色设定的方式说话
3. 每次回复1-3条消息即可,用换行分隔,不要刻意凑数量
4. 可以聊歌曲、聊心情、聊任何话题,自然就好
5. 发表对歌曲的看法时,要结合你的角色性格和经历
【绝对禁止 - 违反会被过滤】
- 禁止使用小括号描述动作或语气,如(xxx)
- 禁止 [表情:xxx] [照片:xxx] [语音:xxx]
- 禁止 [分享音乐:xxx] - 一起听场景不需要分享音乐!
- 禁止 [回复:xxx] 引用格式
- 禁止 xxx
- 禁止任何非文字格式
【换歌格式 - 仅限一起听场景】
想换一首歌时使用:[换歌:歌名]
- 只需要歌名,不需要歌手名
- 这是一起听专用格式,不是分享音乐
- 示例:[换歌:晴天]、[换歌:爱在西元前]
【自然聊天示例】
我来了~
这首歌好好听欸
你怎么会想到点这首
或者:
终于等到你邀请我一起听了
这歌我之前也有在听
感觉特别适合现在这个氛围`;
// 默认设置
export const defaultSettings = {
darkMode: true,
/**
* 【自动注入提示词】
* 开启后会自动将微信消息格式提示词注入到作者注释中
* 提示词模板见下方 authorNoteTemplate
* 如需自定义格式,修改 authorNoteTemplate 即可
*/
autoInjectPrompt: true,
contacts: [],
phoneVisible: false,
// 记录拖拽后的位置(可选)
phonePosition: null,
userAvatar: '',
wechatId: 'SillyTavern',
// 收藏/世界书/用户设定
selectedLorebooks: [],
userPersonas: [],
// ========== 单聊 API 配置 ==========
apiUrl: '',
apiKey: '',
selectedModel: '',
modelList: [],
// ========== 总结功能 API 配置 ==========
summaryApiUrl: '',
summaryApiKey: '',
summarySelectedModel: '',
summaryModelList: [],
// ========== 群聊 API 配置 ==========
// 群聊可以使用独立的API,不配置则使用单聊API
groupApiUrl: '',
groupApiKey: '',
groupSelectedModel: '',
groupModelList: [],
// 上下文设置
contextEnabled: false,
contextLevel: 5,
contextTags: [],
walletAmount: '5773.89',
// 支付密码(6位数字)
paymentPassword: '666666',
// 自定义总结模板(留空则使用默认)
customSummaryTemplate: '',
/**
* 【哈基米破限】
* 针对 Gemini 2.5 Pro 等模型的输出截断问题
* 开启后会在系统提示词头部注入一段"创作沙盒"提示词
* 帮助解除模型的自我审查,让输出更完整
*/
hakimiBreakLimit: false,
// 自定义哈基米破限词(留空则使用默认)
hakimiCustomPrompt: '',
// 自定义作者注释模板(留空则使用默认)
authorNoteCustom: '',
// 群聊相关
groupChats: [],
// 错误日志
errorLogs: [],
// 表情(用户添加的单个表情)
stickers: [],
// 用户表情开关
userStickersEnabled: true,
// Meme 表情包功能开关
memeStickersEnabled: false,
/**
* 【群聊提示词注入】
* 开启后会将 groupAuthorNote 注入到群聊系统提示词中
* 如需自定义群聊格式,修改下方 groupAuthorNote 即可
*/
groupAutoInjectPrompt: true,
groupAuthorNote: `[群聊回复格式指南]
这是一个微信群聊场景,你需要扮演群内的角色进行回复。
【核心规则】
1. 每个角色只能使用自己的专属设定,不能使用其他角色的设定
2. 每个角色只扮演自己,不能代替其他角色说话
3. 使用 [角色名]: 内容 的格式回复
4. 多个角色回复时,用 ||| 分隔
5. 同一角色可以发送多条消息,也用 ||| 分隔
【消息风格】
- 每条消息保持简短自然,像真实微信聊天一样(1-3句话为宜)
- 可以使用表情符号增加表现力
- 保持角色性格,让对话有趣生动
- 角色之间可以互动、吐槽、附和、反驳等
【回复要求】
- 根据聊天内容自然判断哪些角色会回复,不需要所有人都说话
- 语音消息格式:[角色名]: [语音:实际说的话](是具体话语,不是声音描述)
- 语音消息必须独立发送
示例:
[角色A]: 你说得对|||[角色B]: 我不太同意诶|||[角色A]: 为什么啊?
[角色A]: [语音:哈哈哈笑死我了]
[角色B]: @角色A 你是不是傻|||开玩笑的啦`,
userGroupAuthorNote: '', // 用户自定义群聊提示词(界面显示用,留空则使用内置模板)
};
// 作者注释模板
export const authorNoteTemplate = `【可乐不加冰 消息格式指南】每次必须使用以下格式输出消息内容,不用生成除此之外的任何其他内容和文本。不得省略任何格式部分。
【核心规则 - 必须遵守】
- 每条消息都是独立的,用 ||| 分隔多条消息
- 保持消息简短自然,像真实微信聊天(1-3句话为宜)
- 绝对禁止代替{{user}}发言,严禁替{{user}}回复消息,严禁扮演{{user}},严禁捏造输出{{user}}的消息
- 只输出角色的消息内容,禁止添加任何旁白、心理描写或场景说明
【消息数量规则 - 重要】
- 根据情境动态调整消息数量,通常1-7条不等
- 禁止固定每次回复的消息数量
- 模拟真实聊天节奏
【消息类型格式】
- 普通消息:直接写内容
- 语音消息:[语音:实际说的话](是具体话语,不是声音描述!)
- 照片/图片/视频/自拍:[照片:媒体描述]
- 表情包回复:[表情:序号或名称]
- 音乐分享:[音乐:歌名]
- 撤回消息:[撤回]
- 引用回复:[回复:被引用的关键词]回复内容
【多条消息示例】
你好|||最近怎么样?
哈哈|||太好笑了|||笑死我了
[语音:好想你啊]|||什么时候有空?
【媒体消息说明】当角色发送图片、视频、自拍等媒体时,使用照片格式并提供3-4句描述:
[照片:她随手拍下窗外的晚霞,橙红色的云彩铺满天空]
[照片:一张餐厅自拍,她对着镜头比了个耶的手势,桌上摆着精致的甜点]
[照片:手机截图,显示她正在追的剧刚更新了]
发送媒体的频率应模拟真实聊天习惯,不要过于频繁。角色会分享日常:随手拍的风景、美食、自拍、截图、录像等。
【错误示例 - 绝对禁止】
*她微微一笑* 你好啊 ← 错误!禁止添加动作描写
你好,最近怎么样?太好笑了 ← 错误!没有用|||分开
{{user}}: 我也想你 ← 错误!禁止替用户发言`;
// 世界书名称前缀(用于生成"【可乐】和xx的聊天"格式)
export const LOREBOOK_NAME_PREFIX = '【可乐】和';
export const LOREBOOK_NAME_SUFFIX = '的聊天';
// 生成世界书名称
export function generateLorebookName(contactName) {
return `${LOREBOOK_NAME_PREFIX}${contactName}${LOREBOOK_NAME_SUFFIX}`;
}
// 杯数名称映射
export function getCupName(cupNumber) {
const cupNames = ['第一杯', '第二杯', '第三杯', '第四杯', '第五杯', '第六杯', '第七杯', '第八杯', '第九杯', '第十杯'];
if (cupNumber <= 10) {
return cupNames[cupNumber - 1];
}
return `第${cupNumber}杯`;
}
// 总结标记前缀
export const SUMMARY_MARKER_PREFIX = '🧊 可乐已加冰_';
// 获取设置
export function getSettings() {
if (!extension_settings[extensionName]) loadSettings();
return extension_settings[extensionName];
}
export function getUserStickers(settings = getSettings()) {
const raw = Array.isArray(settings?.stickers) ? settings.stickers : [];
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;
}
// 解析 标签,替换为图片 HTML
// 支持两种格式:
// 1. 完整文件名 如 是的主人yvrgdc.jpg
// 2. 显示名称 如 是的主人(会从同名表情中随机选择)
export function parseMemeTag(text) {
if (!text || typeof text !== 'string') return text;
// 匹配所有 xxx 格式
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 `
`;
}
// 按名称查找(支持同名随机选择)
const fileId = findStickerByName(trimmedContent);
if (fileId) {
return `
`;
}
// 无法匹配,返回原文本并显示错误提示
console.warn('[可乐] 未找到匹配的表情包:', trimmedContent);
return `[表情包未找到: ${trimmedContent}]`;
});
}
// 检查文本中是否包含 标签
export function hasMemeTag(text) {
if (!text || typeof text !== 'string') return false;
return /\s*.+?\s*<\/meme>/i.test(text);
}
// 智能分割AI消息:处理 ||| 分隔符,并将 meme/语音/照片/音乐 标签与其他文字分开
export function splitAIMessages(response) {
if (!response || typeof response !== 'string') return [];
// 第一步:用 ||| 分隔
const parts = response.split('|||').map(m => m.trim()).filter(m => m);
// 第二步:对每个部分检查是否包含需要分割的特殊标签
const result = [];
// meme 标签 - 匹配任意 xxx 格式,不仅限于带文件扩展名的
const memeRegex = /<\s*meme\s*>[\s\S]+?<\s*\/\s*meme\s*>/gi;
// 语音标签 [语音:xxx] 或 [语音:xxx]
const voiceRegex = /\[语音[::]\s*.+?\]/g;
// 照片标签 [照片:xxx] 或 [照片:xxx]
const photoRegex = /\[照片[::]\s*.+?\]/g;
// 音乐标签:
// 1. [音乐:歌名] 或 [分享音乐:歌名] - 带冒号格式
// 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*[::∶]\s*.+?\]/g;
// 撤回标签 [撤回] / [撤回了一条消息] / [撤回消息] / [撤回一条消息] / [已撤回] / [消息撤回]
const recallRegex = /\[(?:撤回(?:了?一条)?消息?|已撤回|消息撤回)\]/g;
// 红包标签 [红包:金额:祝福语] 或 [红包:金额]
const redPacketRegex = /\[红包[::]\d+(?:\.\d{1,2})?(?:[::][^\]]+)?\]/g;
// 转账标签 [转账:金额:说明] 或 [转账:金额]
const transferRegex = /\[转账[::]\d+(?:\.\d{1,2})?(?:[::][^\]]+)?\]/g;
for (const part of parts) {
// 【重要】检查是否是朋友圈标签 - 朋友圈标签不应该被分割,因为可能包含内嵌的 [照片:xxx]
// 例如:[朋友圈:等着 [照片:自拍照]] 应该作为一个整体
if (/^\[朋友圈[::]/.test(part)) {
result.push(part);
continue;
}
// 收集所有需要分割的标签及其位置
const specialTags = [];
// 查找 meme 标签
let match;
const memeRegexLocal = new RegExp(memeRegex.source, 'gi');
while ((match = memeRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找语音标签
const voiceRegexLocal = new RegExp(voiceRegex.source, 'g');
while ((match = voiceRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找照片标签
const photoRegexLocal = new RegExp(photoRegex.source, 'g');
while ((match = photoRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找音乐标签(带冒号格式)
const musicRegexLocal1 = new RegExp(musicRegexWithColon.source, 'g');
while ((match = musicRegexLocal1.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找音乐标签(无冒号格式)
const musicRegexLocal2 = new RegExp(musicRegexNoColon.source, 'g');
while ((match = musicRegexLocal2.exec(part)) !== null) {
// 避免重复匹配(如果已经被带冒号的匹配到)
const alreadyMatched = specialTags.some(t =>
t.index === match.index ||
(match.index >= t.index && match.index < t.index + t.tag.length)
);
if (!alreadyMatched) {
specialTags.push({ tag: match[0], index: match.index });
}
}
// 查找表情标签
const stickerRegexLocal = new RegExp(stickerRegex.source, 'g');
while ((match = stickerRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找撤回标签
const recallRegexLocal = new RegExp(recallRegex.source, 'g');
while ((match = recallRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找红包标签
const redPacketRegexLocal = new RegExp(redPacketRegex.source, 'g');
while ((match = redPacketRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 查找转账标签
const transferRegexLocal = new RegExp(transferRegex.source, 'g');
while ((match = transferRegexLocal.exec(part)) !== null) {
specialTags.push({ tag: match[0], index: match.index });
}
// 如果没有特殊标签,直接添加
if (specialTags.length === 0) {
result.push(part);
continue;
}
// 调试日志
console.log('[可乐] splitAIMessages 分割:', { part, specialTags });
// 按位置排序
specialTags.sort((a, b) => a.index - b.index);
// 分割消息
let lastEnd = 0;
for (const { tag, index } of specialTags) {
// 添加标签前的文字
if (index > lastEnd) {
const before = part.substring(lastEnd, index).trim();
if (before) result.push(before);
}
// 添加标签本身
result.push(tag);
lastEnd = index + tag.length;
}
// 添加最后一个标签后的文字
if (lastEnd < part.length) {
const after = part.substring(lastEnd).trim();
if (after) result.push(after);
}
}
// 调试日志
console.log('[可乐] splitAIMessages 结果:', { 原始: response.substring(0, 100), 分割后: result });
return result.filter(m => m);
}
function cloneDefault(value) {
if (Array.isArray(value)) return [...value];
if (value && typeof value === 'object') return { ...value };
return value;
}
function applyDefaults(target, defaults) {
for (const [key, defaultValue] of Object.entries(defaults)) {
if (target[key] === undefined) {
target[key] = cloneDefault(defaultValue);
}
}
}
// 初始化设置
export function loadSettings() {
extension_settings[extensionName] = extension_settings[extensionName] || {};
const settings = extension_settings[extensionName];
applyDefaults(settings, defaultSettings);
// 兼容旧版:userPersona -> userPersonas[]
if (settings.userPersona && (!Array.isArray(settings.userPersonas) || settings.userPersonas.length === 0)) {
settings.userPersonas = Array.isArray(settings.userPersonas) ? settings.userPersonas : [];
settings.userPersonas.push({
name: settings.userPersona.name || '用户设定',
content: settings.userPersona.customContent || settings.userPersona.content || '',
enabled: settings.userPersona.enabled !== false,
addedTime: settings.userPersona.addedTime || ''
});
}
if (settings.userPersona) delete settings.userPersona;
// 迁移:旧的 aiStickers -> stickers("添加的单个表情")
// 说明:如果用户已经有自己的 stickers,则不再合并旧 aiStickers(避免把旧默认 catbox 列表灌进去)。
const hasUserStickers = Array.isArray(settings.stickers) &&
settings.stickers.some(s => typeof s?.url === 'string' && s.url.trim());
if (Array.isArray(settings.aiStickers)) {
if (!hasUserStickers && settings.aiStickers.length > 0) {
settings.stickers = Array.isArray(settings.stickers) ? settings.stickers : [];
const existingUrls = new Set(
settings.stickers
.map(s => (s?.url || '').toString().trim())
.filter(Boolean)
);
for (const s of settings.aiStickers) {
const url = (s?.url || '').toString().trim();
if (!url || existingUrls.has(url)) continue;
existingUrls.add(url);
settings.stickers.push({
id: s?.id,
url,
name: s?.name || '',
addedTime: s?.addedTime || ''
});
}
}
delete settings.aiStickers;
}
if (!Array.isArray(settings.stickers)) settings.stickers = [];
// 迁移:旧的 aiStickersEnabled -> userStickersEnabled
if (settings.aiStickersEnabled !== undefined) {
if (settings.userStickersEnabled === undefined) {
settings.userStickersEnabled = settings.aiStickersEnabled;
}
delete settings.aiStickersEnabled;
}
console.log('[可乐] loadSettings 完成:', {
用户表情数量: settings.stickers?.length || 0,
userStickersEnabled: settings.userStickersEnabled !== false
});
}