-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathutils.js
More file actions
486 lines (415 loc) · 22 KB
/
Copy pathutils.js
File metadata and controls
486 lines (415 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
import { doc, parentWin } from './context.js';
import { state } from './state.js';
export function log(msg) { console.log('[CharManager]', msg); }
export function truncate(t, l) {
return !t ? '(无)' : t.length > l ? t.slice(0, l) + '...' : t;
}
export function formatSize(b) {
if (!b || b === 0) return '0B';
const k = 1024, s = ['B', 'KB', 'MB', 'GB']; // 建议加上 GB
const i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + s[i];
}
export function escapeHtml(t) {
if (!t) return '';
return String(t)
.replace(/&/g, '&' + 'amp;')
.replace(/</g, '&' + 'lt;')
.replace(/>/g, '&' + 'gt;')
.replace(/"/g, '&' + 'quot;')
.replace(/'/g, '&' + '#039;');
}
/**
* 标签数据清洗工具函数
* 过滤无效标签并限制数量
* @param {any} tags - 待清洗的标签数据(可能是数组或其他类型)
* @param {number} [maxCount] - 最大保留数量(默认读取设置中的 aiMaxTags)
* @returns {string[]} 清洗后的标签数组
*/
export function sanitizeTags(tags, maxCount) {
if (!Array.isArray(tags)) return [];
// 默认使用设置中的最大标签数
const limit = maxCount ?? state.settings.aiMaxTags ?? 5;
return tags
.filter(t => t && typeof t === 'string')
.slice(0, limit);
}
export function formatRichText(text, charName = '', preserveHtml = false) {
if (!text) return '';
let processedText = String(text).trim();
// 规范化换行符 (Windows \r\n 和旧 Mac \r 到 Unix \n)
processedText = processedText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 规范化空白:压缩多个空行为最多2个,移除行尾空格
processedText = processedText
.replace(/[ \t]+$/gm, '') // 移除每行尾部的空格/制表符
.replace(/\n{4,}/g, '\n\n\n') // 压缩4+换行为3个
.replace(/[ \t]{2,}/g, ' '); // 压缩多个空格/制表符为单个空格
// 获取颜色配置
const theme = state.settings.macroColorTheme || 'dark1';
let charColor = '#22D3EE';
let userColor = '#FB923C';
if (theme === 'custom') {
charColor = state.settings.customCharColor || '#22D3EE';
userColor = state.settings.customUserColor || '#FB923C';
} else {
const MACRO_COLORS = {
dark1: { char: '#22D3EE', user: '#FB923C' },
dark2: { char: '#60A5FA', user: '#F472B6' },
dark3: { char: '#F472B6', user: '#60A5FA' },
dark4: { char: '#A855F7', user: '#F59E0B' },
dark5: { char: '#34D399', user: '#60A5FA' },
dark6: { char: '#14B8A6', user: '#FB7185' },
light1: { char: '#0F766E', user: '#C2410C' },
light2: { char: '#6D28D9', user: '#1D4ED8' }
};
if (MACRO_COLORS[theme]) {
charColor = MACRO_COLORS[theme].char;
userColor = MACRO_COLORS[theme].user;
}
}
// 获取引号颜色配置
// 新版预设颜色映射
const QUOTE_COLOR_PRESETS = {
moonMist: '#94A3B8', // 月雾灰蓝 (默认)
seaSalt: '#67B7C2', // 海盐青灰
lavender: '#A3A1D6', // 薰衣草影
amber: '#E5C07B', // 琥珀微光
mint: '#7BDCB5', // 薄荷苔绿
wisteria: '#C4B5FD', // 紫藤轻语
iceLake: '#A5F3FC', // 冰湖浅青
morningStar: '#CBD5E1', // 晨星银
// 兼容旧版设置的颜色映射
purple: '#A3A1D6', // 紫色 -> 薰衣草影
blue: '#67B7C2', // 蓝色 -> 海盐青灰
green: '#7BDCB5', // 绿色 -> 薄荷苔绿
orange: '#E5C07B', // 橙色 -> 琥珀微光
pink: '#C4B5FD' // 粉色 -> 紫藤轻语
};
const quoteColor = state.settings.quoteColorTheme === 'custom'
? (state.settings.customQuoteColor || '#94A3B8')
: QUOTE_COLOR_PRESETS[state.settings.quoteColorTheme] || '#94A3B8';
// 如果保留 HTML (用于带有自定义样式的创作者注释),使用混合方法
if (preserveHtml) {
// 检测内容类型以进行适当的处理
// Ultra CSS: <style> 标签在内容开头 (前200字符) = 完全样式化的卡片
const hasStyleTagAtStart = /^[\s\S]{0,200}<style[^>]*>[\s\S]{50,}<\/style>/i.test(processedText);
const hasStyleTag = /<style[^>]*>[\s\S]*?<\/style>/i.test(processedText);
const hasSignificantHtml = /<(div|table|center|font)[^>]*>/i.test(processedText);
const hasInlineStyles = /style\s*=\s*["'][^"']*(?:display|position|flex|grid)[^"']*["']/i.test(processedText);
// Ultra CSS 模式: <style> 标签在开头且有大量 CSS - 几乎不做处理
if (hasStyleTagAtStart) {
// 只转换 markdown 图片 (安全 - 不会在 CSS 中)
processedText = processedText.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s*=[^)]*)?(?:\s+"[^"]*")?\)/g, (match, alt, src) => {
if (!src.match(/^(https?:\/\/|\/)/i)) return match;
const altAttr = alt ? ` alt="${alt.replace(/"/g, '"')}"` : '';
return `<img src="${src}"${altAttr} class="embedded-image" loading="lazy">`;
});
// 替换 {{user}} 和 {{char}} 占位符 (安全)
processedText = processedText.replace(/\{\{user\}\}/gi, `<span class="cm-macro-user" style="font-weight:bold;color:${userColor};">User</span>`);
processedText = processedText.replace(/\{\{char\}\}/gi, `<span class="cm-macro-char" style="font-weight:bold;color:${charColor};">${escapeHtml(charName) || '{{char}}'}</span>`);
return processedText;
}
// 对于在末尾有 <style> 的内容 (页脚横幅),提取并保护它
let styleBlocks = [];
if (hasStyleTag) {
processedText = processedText.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, (match) => {
const placeholder = `\x00STYLEBLOCK${styleBlocks.length}\x00`;
styleBlocks.push(match);
return placeholder;
});
}
// Pure CSS 模式: 有带布局属性的内联样式 - 跳过文本格式化
const isPureCssMode = hasInlineStyles;
// HTML 模式: 有 HTML 结构标签
const isHtmlMode = hasSignificantHtml;
// 转换 markdown 图片和链接 (对所有模式安全):
// 转换带链接的图片: [](link-url)
processedText = processedText.replace(/\[\!\[([^\]]*)\]\(([^)\s]+)(?:\s*=[^)]*)?(?:\s+"[^"]*")?\)\]\(([^)]+)\)/g, (match, alt, imgSrc, linkHref) => {
if (!imgSrc.match(/^(https?:\/\/|\/)/i)) return match;
const altAttr = alt ? ` alt="${alt.replace(/"/g, '"')}"` : '';
const safeLink = linkHref.match(/^https?:\/\//i) ? linkHref : '#';
return `<a href="${safeLink}" target="_blank" rel="noopener"><img src="${imgSrc}"${altAttr} class="embedded-image" loading="lazy"></a>`;
});
// 转换独立的 markdown 图片:  或  或 
processedText = processedText.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s*=[^)]*)?(?:\s+"[^"]*")?\)/g, (match, alt, src) => {
if (!src.match(/^(https?:\/\/|\/)/i)) return match;
const altAttr = alt ? ` alt="${alt.replace(/"/g, '"')}"` : '';
return `<img src="${src}"${altAttr} class="embedded-image" loading="lazy">`;
});
// 转换 markdown 链接: [text](url)
processedText = processedText.replace(/(?<!!)\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, (match, text, href) => {
return `<a href="${href}" target="_blank" rel="noopener" class="embedded-link">${text}</a>`;
});
// 应用 markdown 文本格式化 (但在 pure CSS 模式下不应用)
if (!isPureCssMode) {
// 粗体: **text** 或 __text__
processedText = processedText.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
processedText = processedText.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体: *text* 或 _text_ (注意不要匹配 URL、路径或 HTML 属性内的内容)
processedText = processedText.replace(/(?<![\w*/"=])\*([^*\n]+?)\*(?![\w*])/g, '<em>$1</em>');
processedText = processedText.replace(/(?<![\w_\/."'=])\s_([^_\n]+?)_(?![\w_])/g, ' <em>$1</em>');
// 删除线: ~~text~~
processedText = processedText.replace(/~~([^~]+?)~~/g, '<del>$1</del>');
}
// 替换 {{user}} 和 {{char}} 占位符
processedText = processedText.replace(/\{\{user\}\}/gi, `<span class="cm-macro-user" style="font-weight:bold;color:${userColor};">User</span>`);
processedText = processedText.replace(/\{\{char\}\}/gi, `<span class="cm-macro-char" style="font-weight:bold;color:${charColor};">${escapeHtml(charName) || '{{char}}'}</span>`);
// 根据模式处理换行
const divCount = (processedText.match(/<div/gi) || []).length;
const isHeavyHtml = divCount > 5 || /<table[^>]*>/i.test(processedText);
if (isPureCssMode || isHeavyHtml) {
// Pure CSS 或 Heavy HTML 模式: 不转换换行 - 布局会处理
} else {
// Mixed/Light HTML / Markdown 模式: 转换换行
processedText = processedText.replace(/\n\n+/g, '<br><br>');
processedText = processedText.replace(/([^>])\n([^<])/g, '$1<br>$2');
}
// 恢复 style 块
styleBlocks.forEach((block, i) => {
processedText = processedText.replace(`\x00STYLEBLOCK${i}\x00`, block);
});
return processedText;
}
// 标准模式: 转义 HTML 以确保安全
const placeholders = [];
// 辅助函数:添加占位符
const addPlaceholder = (html) => {
const placeholder = `__PLACEHOLDER_${placeholders.length}__`;
placeholders.push(html);
return placeholder;
};
// 1. 保留现有的 HTML img 标签 (允许 http/https 和本地路径)
processedText = processedText.replace(/<img\s+[^>]*src=["']((?:https?:\/\/|\/)[^"']+)["'][^>]*\/?>/gi, (match, src) => {
return addPlaceholder(`<img src="${src}" class="embedded-image" loading="lazy">`);
});
// 1b. 保留现有的 HTML audio 标签
processedText = processedText.replace(/<audio[^>]*>[\s\S]*?<\/audio>/gi, (match) => {
if (!match.includes('audio-player')) {
match = match.replace(/<audio/, '<audio class="audio-player embedded-audio"');
}
return addPlaceholder(match);
});
// 1c. 转换 audio source 标签为完整的音频播放器
processedText = processedText.replace(/<source\s+[^>]*src=["']((?:https?:\/\/|\/)[^"']+\.(?:mp3|wav|ogg|m4a|flac|aac))["'][^>]*\/?>/gi, (match, src) => {
const ext = src.split('.').pop().toLowerCase();
return addPlaceholder(`<audio controls class="audio-player embedded-audio" preload="metadata"><source src="${src}" type="audio/${ext}">Your browser does not support audio.</audio>`);
});
// 2. 转换带链接的图片: [](link-url)
processedText = processedText.replace(/\[\!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)/g, (match, alt, imgSrc, linkHref) => {
if (!imgSrc.match(/^(https?:\/\/|\/)/i)) return match;
const altAttr = alt ? ` alt="${alt.replace(/"/g, '"')}"` : '';
const safeLink = linkHref.match(/^https?:\/\//i) ? linkHref : '#';
return addPlaceholder(`<a href="${safeLink}" target="_blank" rel="noopener"><img src="${imgSrc}"${altAttr} class="embedded-image" loading="lazy"></a>`);
});
// 3. 转换独立的 markdown 图片:  或 
processedText = processedText.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, alt, src) => {
if (!src.match(/^(https?:\/\/|\/)/i)) return match;
const altAttr = alt ? ` alt="${alt.replace(/"/g, '"')}"` : '';
return addPlaceholder(`<img src="${src}"${altAttr} class="embedded-image" loading="lazy">`);
});
// 3b. 转换 markdown 音频链接: [any text](url.mp3)
processedText = processedText.replace(/\[([^\]]*)\]\(((?:https?:\/\/|\/)[^)\s]+\.(?:mp3|wav|ogg|m4a|flac|aac))(?:\s+"[^"]*)?\)/gi, (match, text, src) => {
const ext = src.split('.').pop().toLowerCase();
return addPlaceholder(`<audio controls class="audio-player embedded-audio" preload="metadata" title="${escapeHtml(text || 'Audio')}"><source src="${src}" type="audio/${ext}">Your browser does not support audio.</audio>`);
});
// 4. 转换 markdown 链接: [text](url)
processedText = processedText.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, (match, text, href) => {
return addPlaceholder(`<a href="${href}" target="_blank" rel="noopener" class="embedded-link">${escapeHtml(text)}</a>`);
});
// 5. 保留 HTML 标题标签
processedText = processedText.replace(/<(h[1-6])>([^<]*)<\/\1>/gi, (match, tag, content) => {
return addPlaceholder(`<${tag} class="embedded-heading">${escapeHtml(content)}</${tag}>`);
});
// 6. 替换 {{user}} 和 {{char}} 占位符 (在 escapeHtml 之前,使用占位符保护)
// 这样插入的 HTML 不会被后续的引号替换破坏
processedText = processedText.replace(/\{\{user\}\}/gi, () => {
return addPlaceholder(`<span class="cm-macro-user" style="font-weight:bold;color:${userColor};">User</span>`);
});
processedText = processedText.replace(/\{\{char\}\}/gi, () => {
return addPlaceholder(`<span class="cm-macro-char" style="font-weight:bold;color:${charColor};">${escapeHtml(charName) || '{{char}}'}</span>`);
});
// 7. 引号文本染色 (在 escapeHtml 之前,使用占位符保护)
// 英文双引号 ""
processedText = processedText.replace(/"([^"]+)"/g, (match, content) => {
if (content.trim()) {
return addPlaceholder(`<span class="cm-quote-content" style="color:${quoteColor};">"${escapeHtml(content)}"</span>`);
}
return match;
});
// 中文全角引号 ""
processedText = processedText.replace(/"([^"]+)"/g, (match, content) => {
if (content.trim()) {
return addPlaceholder(`<span class="cm-quote-content" style="color:${quoteColor};">"${escapeHtml(content)}"</span>`);
}
return match;
});
// 中文直角引号「」
processedText = processedText.replace(/「([^」]+)」/g, (match, content) => {
if (content.trim()) {
return addPlaceholder(`<span class="cm-quote-content" style="color:${quoteColor};">「${escapeHtml(content)}」</span>`);
}
return match;
});
// 中文直角双引号『』
processedText = processedText.replace(/『([^』]+)』/g, (match, content) => {
if (content.trim()) {
return addPlaceholder(`<span class="cm-quote-content" style="color:${quoteColor};">『${escapeHtml(content)}』</span>`);
}
return match;
});
// 转义 HTML 以防止 XSS
let formatted = escapeHtml(processedText);
// 恢复所有占位符
placeholders.forEach((html, i) => {
formatted = formatted.replace(`__PLACEHOLDER_${i}__`, html);
});
// 转换 markdown 格式
// 粗体: **text** 或 __text__
formatted = formatted.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
formatted = formatted.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体: *text* 或 _text_ (但不在单词或 URL 内)
formatted = formatted.replace(/(?<![\w*])\*([^*]+?)\*(?![\w*])/g, '<em>$1</em>');
formatted = formatted.replace(/(?:^|(?<=\s))_([^_]+?)_(?![\w_])/g, '<em>$1</em>');
// 转换换行 - 双换行使用段落分隔,单换行使用 <br>
formatted = formatted.replace(/\\n/g, '\n'); // 先转换字面量 \n 为实际换行
formatted = formatted.replace(/\n\n+/g, '</p><p>'); // 双+换行变成段落分隔
formatted = formatted.replace(/\n/g, '<br>'); // 单换行变成 <br>
formatted = '<p>' + formatted + '</p>'; // 包裹在段落中
formatted = formatted.replace(/<p><\/p>/g, ''); // 移除空段落
return formatted;
}
export function generateId() {
// 使用 slice 代替过时的 substr
return Date.now().toString() + Math.random().toString(36).slice(2, 11);
}
export function notify(msg, type = 'info') {
try {
// 增加更严谨的判断
if (parentWin && parentWin.toastr) parentWin.toastr[type](msg);
else if (typeof toastr !== 'undefined') toastr[type](msg);
else log(`${type.toUpperCase()}: ${msg}`);
} catch (e) {
console.error('Notify failed', e);
}
}
export async function loadJSZip() {
if (parentWin?.JSZip) return parentWin.JSZip;
if (window.JSZip) return window.JSZip;
return new Promise((resolve, reject) => {
const script = doc.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
script.onload = () => resolve(window.JSZip || parentWin?.JSZip);
script.onerror = () => reject(new Error('无法加载 JSZip 库'));
(doc.head || doc.documentElement).appendChild(script);
});
}
/**
* 计算 Token 数 (简单估算)
*/
export function calculateTokens(text) {
if (!text) return 0;
// 这里的正则在匹配复杂标点或 Emoji 时可能有偏差,
// 但作为"粗略估算"是可以接受的。
const words = text.match(/[\w]+|[\u4e00-\u9fa5]/g);
return words ? words.length : 0;
}
export function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const len = bytes.length;
const GROUP = 3072;
const parts = [];
for (let i = 0; i < len; i += GROUP) {
const chunk = bytes.subarray(i, Math.min(i + GROUP, len));
parts.push(btoa(String.fromCharCode.apply(null, chunk)));
}
return parts.join('');
}
export async function parsePNG(buf) {
try {
const v = new DataView(buf);
let o = 8;
const latin1 = new TextDecoder('latin1');
while (o < buf.byteLength) {
const len = v.getUint32(o); o += 4;
const type = latin1.decode(new Uint8Array(buf, o, 4)); o += 4;
if (type === 'tEXt') {
const ch = new Uint8Array(buf, o, len);
const ni = Array.prototype.indexOf.call(ch, 0);
if (ni > 0) {
const key = latin1.decode(ch.slice(0, ni));
if (key === 'chara' || key === 'ccv3') {
const b64 = latin1.decode(ch.slice(ni + 1));
try {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return JSON.parse(new TextDecoder('utf-8').decode(bytes));
} catch (e) { }
}
}
}
o += len + 4;
if (type === 'IEND') break;
}
} catch (e) { }
return null;
}
/**
* 获取角色文件名(统一回退逻辑)
* @param {object} character - 角色对象
* @returns {string} 文件名或 avatar,失败时返回 'unknown'
*/
export function getCharacterFileName(character) {
return character.fileName || character.avatar || 'unknown';
}
/**
* 检查角色是否有有效标签
* 优先读取插件主数据源 cm_manager.tags,再回退到 data.tags
* @param {object} character - 角色对象
* @returns {boolean} 是否有有效标签
*/
export function checkCharHasTags(character) {
const data = character.data || character;
// 优先检查 cm_manager.tags(插件主数据源)
const cmManagerTags = data.extensions?.cm_manager?.tags;
if (Array.isArray(cmManagerTags)) {
// cm_manager.tags 存在时,以其为准(包括空数组表示"已确认为无标签")
return cmManagerTags.length > 0 && cmManagerTags[0] !== '';
}
// 回退到 data.tags(V2/V3 角色卡原生标签)
const dataTags = data.tags;
if (Array.isArray(dataTags)) {
return dataTags.length > 0 && dataTags[0] !== '';
}
return false;
}
/**
* 获取角色来源链接(按优先级检测多个字段)
* 移植自 Luker 项目的 getCharacterSource 函数
* @param {object} char - 角色对象
* @returns {string} - 来源链接,无则返回空字符串
*/
export function getCharacterSource(char) {
if (!char) return '';
const ext = char.data?.extensions || {};
// 优先级 1: 用户手动设置的 source_url
if (ext.source_url) return ext.source_url;
// 优先级 2: Chub.ai
const chubId = ext.chub?.full_path;
if (chubId) return `https://chub.ai/characters/${chubId}`;
// 优先级 3: Pygmalion
const pygmalionId = ext.pygmalion_id;
if (pygmalionId) return `https://pygmalion.chat/${pygmalionId}`;
// 优先级 4: GitHub
const githubRepo = ext.github_repo;
if (githubRepo) return `https://github.com/${githubRepo}`;
// 优先级 5: RisuAI Realm
const risuId = ext.risuai?.source;
if (Array.isArray(risuId) && risuId.length && typeof risuId[0] === 'string' && risuId[0].startsWith('risurealm:')) {
return `https://realm.risuai.net/character/${risuId[0].split(':')[1]}`;
}
// 优先级 6: Perchance
const perchanceSlug = ext.perchance_data?.slug;
if (perchanceSlug) return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`;
return '';
}