dsh-fish-soul 0.13.9 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.13.9**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.14.0**。
6
6
 
7
7
  ## 能力
8
8
 
package/lib/browse.js CHANGED
@@ -56,7 +56,7 @@ export class BiliBrowser {
56
56
  * @param {(level: string, msg: string) => void} [deps.log]
57
57
  * @param {[number, number]} [deps.watchDelayMs] 两条视频之间"看"多久(真实毫秒)
58
58
  */
59
- constructor({ db, clock, client, getConfig, callLlm, persona, worker, log, watchDelayMs = [8000, 20000], share, note }) {
59
+ constructor({ db, clock, client, getConfig, callLlm, persona, worker, log, watchDelayMs = [8000, 20000], share, note, frames }) {
60
60
  this.share = share ?? null; // 刷到特别想分享的:交给自走层排一条主动消息(带链接)
61
61
  this.note = note ?? null; // 记一条动态
62
62
  this.db = db;
@@ -70,6 +70,7 @@ export class BiliBrowser {
70
70
  this.watchDelayMs = watchDelayMs;
71
71
  this.running = false;
72
72
  this.described = new Map();
73
+ this.framesOf = frames ?? null; // 抽九宫格看画面(可缺:没有就只读文字)
73
74
  }
74
75
 
75
76
  cfg() {
@@ -84,7 +85,15 @@ export class BiliBrowser {
84
85
  share: br.share !== false,
85
86
  favorite: br.favorite === true,
86
87
  favoritePerDay: num(br.favoritePerDay, 2, 0, 10),
88
+ commentReply: br.commentReply === true,
89
+ commentReplyPerDay: num(br.commentReplyPerDay, 1, 0, 5),
90
+ hotComments: num(br.hotComments, 6, 0, 10),
87
91
  danmaku: br.danmaku !== false,
92
+ comment: br.comment === true,
93
+ commentPerDay: num(br.commentPerDay, 1, 0, 5),
94
+ frames: br.frames === true,
95
+ framesPerDay: num(br.framesPerDay, 1, 0, 5),
96
+ framesPerDayChat: num(br.framesPerDayChat, 10, 0, 30),
88
97
  search: br.search === true,
89
98
  searchKeywords: (Array.isArray(br.searchKeywords) ? br.searchKeywords : [])
90
99
  .map((w) => String(w ?? '').trim()).filter((w) => w !== '').slice(0, 20),
@@ -149,7 +158,7 @@ export class BiliBrowser {
149
158
  try { subtitle = await this.client.subtitleText(info.bvid, info.cid, subtitleChars); } catch { /* 没字幕 */ }
150
159
  }
151
160
  let comments = [];
152
- try { comments = await this.client.hotComments(info.aid, 3); } catch { /* 没评论 */ }
161
+ try { comments = await this.client.hotComments(info.aid, this.cfg().hotComments); } catch { /* 没评论 */ }
153
162
  let danmakuLines = [];
154
163
  if (danmaku && typeof this.client.danmaku === 'function') {
155
164
  try { danmakuLines = await this.client.danmaku(info.cid, 12); } catch { /* 没弹幕 / 解不开 */ }
@@ -157,10 +166,40 @@ export class BiliBrowser {
157
166
  return { ...info, subtitle, comments, danmaku: danmakuLines };
158
167
  }
159
168
 
169
+ framesDayKey() {
170
+ return `bili.frames.${this.clock.getRoleDateParts().dateStr}`;
171
+ }
172
+
173
+ framedToday() {
174
+ return Number(getKv(this.db, this.framesDayKey(), 0)) || 0;
175
+ }
176
+
177
+ /** 你发链接给她时抽的帧,另记一本账,不占她自己刷视频的次数。 */
178
+ framesChatDayKey() {
179
+ return `bili.frames.chat.${this.clock.getRoleDateParts().dateStr}`;
180
+ }
181
+
182
+ framedTodayChat() {
183
+ return Number(getKv(this.db, this.framesChatDayKey(), 0)) || 0;
184
+ }
185
+
160
186
  favDayKey() {
161
187
  return `bili.fav.${this.clock.getRoleDateParts().dateStr}`;
162
188
  }
163
189
 
190
+ replyDayKey() {
191
+ return `bili.reply.${this.clock.getRoleDateParts().dateStr}`;
192
+ }
193
+
194
+ repliedToday() {
195
+ return Number(getKv(this.db, this.replyDayKey(), 0)) || 0;
196
+ }
197
+
198
+ /** 最近一次回过的评论(设置页显示用),没回过返回 null。 */
199
+ recentReply() {
200
+ return getKv(this.db, 'bili.reply.last', null) ?? null;
201
+ }
202
+
164
203
  favoritedToday() {
165
204
  return Number(getKv(this.db, this.favDayKey(), 0)) || 0;
166
205
  }
@@ -173,6 +212,14 @@ export class BiliBrowser {
173
212
  return Number(getKv(this.db, this.searchDayKey(), 0)) || 0;
174
213
  }
175
214
 
215
+ commentDayKey() {
216
+ return `bili.comment.${this.clock.getRoleDateParts().dateStr}`;
217
+ }
218
+
219
+ commentedToday() {
220
+ return Number(getKv(this.db, this.commentDayKey(), 0)) || 0;
221
+ }
222
+
176
223
  /** 她最近看过的几条标题(给她自己提搜索词当原料)。 */
177
224
  recentTitles(n = 5) {
178
225
  try {
@@ -234,7 +281,7 @@ export class BiliBrowser {
234
281
  return drafted;
235
282
  }
236
283
 
237
- promptFor(v, like, share = false, favorite = false) {
284
+ promptFor(v, like, share = false, favorite = false, hasFrames = false) {
238
285
  const p = this.persona?.() ?? { name: '鱼', brief: '' };
239
286
  return [
240
287
  `你是「${p.name}」。${p.brief}`.trim(),
@@ -244,6 +291,7 @@ export class BiliBrowser {
244
291
  v.desc ? `简介:${oneLine(v.desc, 200)}` : '',
245
292
  v.subtitle ? `字幕节选:${v.subtitle}` : '(这个视频没有字幕,只能看简介和评论)',
246
293
  v.comments.length > 0 ? `热评:\n${v.comments.map((c) => `- ${c}`).join('\n')}` : '',
294
+ hasFrames ? '画面:附了一张九宫格截图(从开头每 6 秒一格,共 9 格)。如果画面能看清,就在感受里写一句画面里的具体细节——谁在做什么、什么颜色、场景长什么样;实在看不清就写“没看到画面”。' : '',
247
295
  (v.danmaku ?? []).length > 0 ? `弹幕(按时间抽了几条):${v.danmaku.map((d) => oneLine(d, 30)).join(' / ')}` : '',
248
296
  '',
249
297
  '用第一人称、口语化写一两句你看完的感受(大概讲了什么 + 你怎么看),不超过 80 字,不要照抄标题。',
@@ -264,9 +312,147 @@ export class BiliBrowser {
264
312
  return id;
265
313
  }
266
314
 
315
+ /**
316
+ * 要不要抽帧看画面:开关开着、有 cid、不是短片、今天还有额度。
317
+ * 失败只记一条 warn,返回 null —— 绝不因为抽帧失败就看不到视频。
318
+ */
319
+ async frameIfWanted(v, c) {
320
+ if (c.frames !== true || typeof this.framesOf !== 'function') return null;
321
+ if (!v.cid || (Number(v.duration ?? 0) < 120)) return null;
322
+ if (this.framedToday() >= Number(c.framesPerDay ?? 1)) return null;
323
+ try {
324
+ const img = await this.framesOf(v);
325
+ if (img?.data) {
326
+ setKv(this.db, this.framesDayKey(), this.framedToday() + 1);
327
+ return img;
328
+ }
329
+ } catch (error) {
330
+ this.log('warn', `[fish-soul] 抽帧没成(照旧只看文字):${String(error instanceof Error ? error.message : error)}`);
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /** 你发链接过来时抽一帧,额度跟"自己刷"分开。 */
336
+ async frameForLink(v, c) {
337
+ if (c.frames !== true || typeof this.framesOf !== 'function') return null;
338
+ if (!v.cid || (Number(v.duration ?? 0) < 60)) return null;
339
+ if (this.framedTodayChat() >= Number(c.framesPerDayChat ?? 10)) return null;
340
+ try {
341
+ const img = await this.framesOf(v);
342
+ if (img?.data) {
343
+ setKv(this.db, this.framesChatDayKey(), this.framedTodayChat() + 1);
344
+ return img;
345
+ }
346
+ } catch (error) {
347
+ this.log('warn', `[fish-soul] 分享抽帧没成(照旧只看文字):${String(error instanceof Error ? error.message : error)}`);
348
+ }
349
+ return null;
350
+ }
351
+
352
+ /** 把一张九宫格读成一句话,写进聊天时她能看到的那段文字里。 */
353
+ async visualNote(v, frame) {
354
+ try {
355
+ const text = await this.callLlm(
356
+ `这是 B站 视频《${oneLine(v.title, 40)}》开头的九宫格截图(每格约 6 秒)。` +
357
+ '用一句不超过 30 字的话,写画面里看到的具体东西:谁在做什么、什么颜色、什么场景。' +
358
+ '看不清就只写「没看清」。不要分点、不要引号、不要写"这张图"。',
359
+ { feature: 'browse', maxTokens: 80, images: [frame] }
360
+ );
361
+ const t = oneLine(String(text ?? '').replace(/^["“]|["”]$/g, ''), 40);
362
+ return t && t !== '没看清' ? t : '';
363
+ } catch (error) {
364
+ this.log('warn', `[fish-soul] 读画面没成:${String(error instanceof Error ? error.message : error)}`);
365
+ return '';
366
+ }
367
+ }
368
+
369
+ /** 评论区留一句的提示词(短、口语、像真人随手写的)。 */
370
+ commentPrompt(v, impression) {
371
+ const p = this.persona?.() ?? { name: '鱼', brief: '' };
372
+ return [
373
+ `你是「${p.name}」。${p.brief}`.trim(),
374
+ `你刚看完 B站 视频《${oneLine(v.title, 60)}》${v.owner ? `(UP主 ${oneLine(v.owner, 20)})` : ''}。`,
375
+ `你自己记下的感受:${oneLine(impression, 100)}`,
376
+ '现在想在评论区留一句(不是回谁,就是自己看完想说)。',
377
+ '要求:第一人称、口语、一到两句,不超过 25 个字;像真人随手写的,不要「打卡」「沙发」「前排」这类;不@人、不贴链接、不复述标题、不评价 UP主 本人。',
378
+ '只想说「好看」这种空话,就只输出 [不评]。否则只输出那句话本身。'
379
+ ].filter(Boolean).join('\n');
380
+ }
381
+
382
+ /** 在视频底下留一句。成功返回评论文本,没留返回 ''。 */
383
+ async commentOn(v, impression) {
384
+ let text = '';
385
+ try {
386
+ const out = String((await this.callLlm(this.commentPrompt(v, impression))) ?? '').trim();
387
+ if (/\[不评\]/.test(out)) return '';
388
+ text = oneLine(out.replace(/^[「“"']|[」”"']$/g, ''), 25);
389
+ } catch (error) {
390
+ this.log('warn', `[fish-soul] 想评论没想出来:${String(error instanceof Error ? error.message : error)}`);
391
+ return '';
392
+ }
393
+ if (text === '') return '';
394
+ try {
395
+ await Promise.race([
396
+ this.client.replyComment({ type: 1, oid: v.aid, root: 0, parent: 0, message: text }),
397
+ new Promise((_, reject) => setTimeout(() => reject(new Error('评论发帖超时')), 15_000))
398
+ ]);
399
+ setKv(this.db, this.commentDayKey(), this.commentedToday() + 1);
400
+ this.log('info', `[fish-soul] 在《${oneLine(v.title, 30)}》评论区留了一句:${text}`);
401
+ return text;
402
+ } catch (error) {
403
+ this.log('warn', `[fish-soul] 评论没发出去:${String(error instanceof Error ? error.message : error)}`);
404
+ return '';
405
+ }
406
+ }
407
+
408
+ /**
409
+ * 楼中楼:从视频现有评论里挑一条值得搭话的,回一句(只回一条,不追着聊)。
410
+ * 模型输出「序号|回复内容」;[不回] / 序号不合法 / 内容为空都算没回。
411
+ */
412
+ async commentReplyOn(v) {
413
+ let list = [];
414
+ try {
415
+ list = await this.client.commentList(v.aid, 10);
416
+ } catch (error) {
417
+ this.log('warn', `[fish-soul] 读评论没成:${String(error instanceof Error ? error.message : error)}`);
418
+ return '';
419
+ }
420
+ const me = String(this.client.uid ?? '');
421
+ list = (list ?? []).filter((x) => x.content && x.rpid && String(x.mid) !== me);
422
+ if (list.length === 0) return '';
423
+ const p = this.persona?.() ?? { name: '鱼', brief: '' };
424
+ const lines = [
425
+ `你是「${p.name}」。${p.brief}`.trim(),
426
+ `你刚看完 B站 视频《${oneLine(v.title, 50)}》。评论区长这样:`,
427
+ ...list.map((x, i) => `${i + 1}. ${x.name || '有人'}:${x.content}`),
428
+ '挑一条你想接话的,回一句。要求:第一人称、口语、一到两句,不超过 30 个字;',
429
+ '不要复述他说的话,不要@人,不要引战、不要跟人抬杠,不评价 UP主 本人,广告和骂人的别接。',
430
+ '没有想接的就只输出 [不回]。要回就只输出「序号|回复内容」,两行都不要解释。'
431
+ ];
432
+ const out = String((await this.callLlm(lines.join('\n'))) ?? '').trim();
433
+ if (out === '' || /\[不回\]/.test(out)) return '';
434
+ const m = out.match(/^\s*(\d{1,2})\s*[||]\s*([\s\S]+)$/);
435
+ if (!m) return '';
436
+ const pick = list[Number(m[1]) - 1];
437
+ const text = oneLine(m[2].replace(/^[「“"']|[」”"']$/g, ''), 60);
438
+ if (!pick || text === '') return '';
439
+ try {
440
+ await this.client.replyComment({ type: 1, oid: v.aid, root: pick.rpid, parent: 0, message: text });
441
+ setKv(this.db, this.replyDayKey(), this.repliedToday() + 1);
442
+ setKv(this.db, 'bili.reply.last', { bvid: v.bvid, title: oneLine(v.title, 40), name: pick.name, text, root: pick.rpid, at: Date.now() });
443
+ this.log('info', `[fish-soul] 回了 @${pick.name} 的评论:${text}`);
444
+ return text;
445
+ } catch (error) {
446
+ this.log('warn', `[fish-soul] 回评论没发出去:${String(error instanceof Error ? error.message : error)}`);
447
+ return '';
448
+ }
449
+ }
450
+
267
451
  async watch(item, like, share = false, favorite = false) {
268
452
  const v = await this.gather(item.bvid);
269
- const out = await this.callLlm(this.promptFor(v, like, share, favorite));
453
+ const c = this.cfg();
454
+ const frame = await this.frameIfWanted(v, c);
455
+ const out = await this.callLlm(this.promptFor(v, like, share, favorite, frame !== null), frame ? { images: [frame] } : undefined);
270
456
  const raw = String(out ?? '').trim();
271
457
  const skipped = /\[划走\]/.test(raw);
272
458
  const liked = like && /\[点赞\]/.test(raw);
@@ -296,13 +482,24 @@ export class BiliBrowser {
296
482
  this.log('warn', `[fish-soul] 收藏失败:${String(error instanceof Error ? error.message : error)}`);
297
483
  }
298
484
  }
485
+ // 评论区留一句:只在真看完、有感受、开关开着、今天还没到额度时
486
+ let commented = '';
487
+ if (!skipped && impression !== '' && c.comment && v.aid && this.commentedToday() < c.commentPerDay) {
488
+ commented = await this.commentOn(v, impression);
489
+ }
490
+ // 楼中楼:回一条别人的评论(默认关,单独额度)
491
+ let replied = '';
492
+ const replyCfg = this.cfg();
493
+ if (!skipped && replyCfg.commentReply && v.aid && this.repliedToday() < replyCfg.commentReplyPerDay) {
494
+ replied = await this.commentReplyOn(v);
495
+ }
299
496
  this.db.prepare(`
300
497
  INSERT INTO bili_seen (bvid, aid, title, owner_name, source, impression, skipped, liked, favorited, memory_id, seen_at, seen_real_at)
301
498
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
302
499
  ON CONFLICT(bvid) DO NOTHING
303
500
  `).run(v.bvid, v.aid, oneLine(v.title, 120), oneLine(v.owner, 40), item.source ?? 'recommend', impression,
304
501
  skipped ? 1 : 0, liked ? 1 : 0, favorited ? 1 : 0, memoryId, this.clock.now(), Date.now());
305
- return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, favorited, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
502
+ return { bvid: v.bvid, title: v.title, owner: v.owner, impression, skipped, liked, favorited, commented, replied, share: wantShare && impression !== '', url: `https://www.bilibili.com/video/${v.bvid}` };
306
503
  }
307
504
 
308
505
  /** 刷一轮。返回看过的视频。 */
@@ -363,10 +560,13 @@ export class BiliBrowser {
363
560
  if (hit !== undefined && Date.now() - hit.at < DESCRIBE_TTL_MS) { notes.push(hit.text); continue; }
364
561
  try {
365
562
  const v = await this.gather(bvid, { subtitleChars: c.subtitleChars, danmaku: false });
563
+ const frame = await this.frameForLink(v, c);
564
+ const visual = frame ? await this.visualNote(v, frame) : '';
366
565
  const text = `【视频 ${bvid}:《${oneLine(v.title, 60)}》UP主 ${oneLine(v.owner, 20) || '未知'}` +
367
566
  (v.desc ? `;简介:${oneLine(v.desc, c.descChars)}` : '') +
368
567
  (v.subtitle ? `;字幕节选:${oneLine(v.subtitle, c.subtitleChars)}` : '') +
369
- (v.comments[0] ? `;热评:${oneLine(v.comments[0], c.hotCommentChars)}` : '') + '】';
568
+ (v.comments[0] ? `;热评:${oneLine(v.comments[0], c.hotCommentChars)}` : '') +
569
+ (visual ? `;画面:${visual}` : '') + '】';
370
570
  this.described.set(bvid, { text, at: Date.now() });
371
571
  if (this.described.size > 200) this.described.delete(this.described.keys().next().value);
372
572
  notes.push(text);
@@ -546,6 +546,24 @@ export class BiliClient {
546
546
  return body.map((b) => String(b.content ?? '')).join(',').slice(0, maxChars);
547
547
  }
548
548
 
549
+ /**
550
+ * 视频评论列表(给"回一条别人的评论"用):带 rpid / 作者 / 内容,按热度取前 n 条。
551
+ */
552
+ async commentList(aid, n = 10) {
553
+ if (!aid) return [];
554
+ const json = await this.call('api', '/x/v2/reply/wbi/main', { wbi: true, params: { type: 1, oid: aid, mode: 3, ps: 20 } });
555
+ return (json.data?.replies ?? [])
556
+ .map((r) => ({
557
+ rpid: String(r?.rpid ?? ''),
558
+ mid: String(r?.mid ?? ''),
559
+ name: String(r?.member?.uname ?? '').slice(0, 20),
560
+ content: String(r?.content?.message ?? '').replace(/\s+/g, ' ').slice(0, 120),
561
+ like: Number(r?.like ?? 0)
562
+ }))
563
+ .filter((x) => x.rpid && x.mid && x.content !== '')
564
+ .slice(0, n);
565
+ }
566
+
549
567
  async hotComments(aid, n = 3) {
550
568
  if (!aid) return [];
551
569
  const json = await this.call('api', '/x/v2/reply/wbi/main', { wbi: true, params: { type: 1, oid: aid, mode: 3, ps: 5 } });
@@ -653,7 +671,7 @@ export class BiliClient {
653
671
  */
654
672
  async atFeed() {
655
673
  const json = await this.call('api', '/x/msgfeed/at', { params: { platform: 'web', build: 0, mobi_app: 'web' }, referer: 'https://message.bilibili.com/' });
656
- return (json.data?.items ?? []).map((it) => {
674
+ const list = (json.data?.items ?? []).map((it) => {
657
675
  const x = it?.item ?? {};
658
676
  return {
659
677
  id: it?.id === undefined || it?.id === null ? '' : `at:${it.id}`,
@@ -664,6 +682,8 @@ export class BiliClient {
664
682
  oid: String(x.subject_id ?? ''),
665
683
  rpid: String(x.source_id ?? ''),
666
684
  root: String(x.root_id && String(x.root_id) !== '0' ? x.root_id : x.source_id ?? ''),
685
+ // 他是「回复某条评论」时 @ 她的吗?target_id 指向被回复的那条(0 = 顶层新评论)
686
+ targetId: String(x.target_id ?? '0'),
667
687
  title: String(x.title ?? ''),
668
688
  content: String(x.source_content ?? ''),
669
689
  target: '',
@@ -672,6 +692,36 @@ export class BiliClient {
672
692
  isComment: String(x.type ?? '') === 'reply'
673
693
  };
674
694
  }).filter((r) => r.id !== '' && r.isComment && r.oid !== '' && r.rpid !== '' && r.type > 0);
695
+ // 「回复某条评论 + @ 她」时,把她被回的那条原文也带上(顶层 @ 没有这条,跳过)
696
+ for (const it of list) {
697
+ if (it.targetId === '' || it.targetId === '0' || it.targetId === it.rpid) continue;
698
+ const text = await this.commentText({ type: it.type, oid: it.oid, root: it.root, rpid: it.targetId });
699
+ if (text !== '') it.target = text;
700
+ }
701
+ return list;
702
+ }
703
+
704
+ /** 取一条评论的原文(被 @ 时用来还原"他回的是谁"),拿不到返回空串。 */
705
+ async commentText({ type, oid, root, rpid }) {
706
+ const flat = (s) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
707
+ const want = (r) => (r && String(r.rpid) === String(rpid) ? flat(`${r?.member?.uname ?? ''}:${r?.content?.message ?? ''}`) : '');
708
+ try {
709
+ for (const pn of [1, 2]) {
710
+ const json = await this.call('api', '/x/v2/reply/reply', {
711
+ params: { type, oid, root, ps: 50, pn },
712
+ referer: 'https://www.bilibili.com/'
713
+ });
714
+ const data = json?.data ?? {};
715
+ const hitRoot = want(data.root);
716
+ if (hitRoot !== '') return hitRoot;
717
+ for (const r of data.replies ?? []) {
718
+ const hit = want(r);
719
+ if (hit !== '') return hit;
720
+ }
721
+ if ((data.replies ?? []).length < 50) break;
722
+ }
723
+ } catch { /* 拿不到就当没有 */ }
724
+ return '';
675
725
  }
676
726
 
677
727
  /** 在评论区回复一条评论(root = 楼主评论,parent = 要回的那条)。返回新评论 id。 */
@@ -44,6 +44,54 @@ function parseCqString(raw) {
44
44
  return out;
45
45
  }
46
46
 
47
+ /**
48
+ * QQ 小黄脸 → 中文名。
49
+ * 0–60 是经典 QQ 表情(公认的那套);61 以上来自 QQ 系统表情目录(SnowLuma 自带 `sys-face-catalog.json`),
50
+ * 覆盖新表情(比如 277 = 汪汪)。认不出的 id 显示「表情(数字)」,不让它变成一句空话。
51
+ */
52
+ export const FACE_NAMES = {
53
+ 0: '惊讶', 1: '撇嘴', 2: '色', 3: '发呆', 4: '得意', 5: '流泪', 6: '害羞', 7: '闭嘴', 8: '睡', 9: '大哭',
54
+ 10: '尴尬', 11: '发怒', 12: '调皮', 13: '呲牙', 14: '微笑', 15: '难过', 16: '酷', 17: '抓狂', 18: '吐', 19: '偷笑',
55
+ 20: '可爱', 21: '白眼', 22: '傲慢', 23: '饥饿', 24: '困', 25: '惊恐', 26: '流汗', 27: '憨笑', 28: '大兵', 29: '奋斗',
56
+ 30: '咒骂', 31: '疑问', 32: '嘘', 33: '晕', 34: '折磨', 35: '衰', 36: '骷髅', 37: '敲打', 38: '再见', 39: '发抖',
57
+ 40: '爱情', 41: '跳跳', 42: '拥抱', 43: '蛋糕', 44: '闪电', 45: '炸弹', 46: '刀', 47: '足球', 48: '瓢虫', 49: '便便',
58
+ 50: '咖啡', 51: '饭', 52: '药', 53: '玫瑰', 54: '凋谢', 55: '示爱', 56: '爱心', 57: '心碎', 58: '会议', 59: '包',
59
+ 60: '礼物', 63: '玫瑰', 64: '凋谢', 66: '爱心', 67: '心碎', 74: '太阳', 75: '月亮', 76: '赞', 77: '踩', 78: '握手',
60
+ 79: '胜利', 85: '飞吻', 86: '怄火', 89: '西瓜', 96: '冷汗', 97: '擦汗', 98: '抠鼻', 99: '鼓掌', 100: '糗大了', 101: '坏笑',
61
+ 102: '左哼哼', 103: '右哼哼', 104: '哈欠', 105: '鄙视', 106: '委屈', 107: '快哭了', 108: '阴险', 109: '左亲亲', 110: '吓', 111: '可怜',
62
+ 112: '菜刀', 114: '篮球', 116: '示爱', 118: '抱拳', 119: '勾引', 120: '拳头', 121: '差劲', 123: 'NO', 124: 'OK', 125: '转圈',
63
+ 129: '挥手', 137: '鞭炮', 144: '喝彩', 146: '爆筋', 147: '棒棒糖', 169: '手枪', 171: '茶', 172: '眨眼睛', 173: '泪奔', 174: '无奈',
64
+ 175: '卖萌', 176: '小纠结', 177: '喷血', 178: '斜眼笑', 179: 'doge', 181: '戳一戳', 182: '笑哭', 183: '我最美', 185: '羊驼', 187: '幽灵',
65
+ 201: '点赞', 212: '托腮', 262: '脑阔疼', 263: '沧桑', 264: '捂脸', 265: '辣眼睛', 266: '哦哟', 267: '头秃', 268: '问号脸', 269: '暗中观察',
66
+ 270: 'emm', 271: '吃瓜', 272: '呵呵哒', 273: '我酸了', 277: '汪汪', 281: '无眼笑', 282: '敬礼', 283: '狂笑', 284: '面无表情', 285: '摸鱼',
67
+ 286: '魔鬼笑', 287: '哦', 289: '睁眼', 293: '摸锦鲤', 294: '期待', 295: '拿到红包', 297: '拜谢', 298: '元宝', 299: '牛啊', 300: '胖三斤',
68
+ 302: '左拜年', 303: '右拜年', 305: '右亲亲', 306: '牛气冲天', 307: '喵喵', 311: '打call', 312: '变形', 314: '仔细分析', 317: '菜汪', 318: '崇拜',
69
+ 319: '比心', 320: '庆祝', 323: '嫌弃', 324: '吃糖', 325: '惊吓', 326: '生气', 332: '举牌牌', 333: '烟花', 334: '虎虎生威', 336: '豹富',
70
+ 337: '花朵脸', 338: '我想开了', 339: '舔屏', 341: '打招呼', 342: '酸Q', 343: '我方了', 344: '大怨种', 345: '红包多多', 346: '你真棒棒', 347: '大展宏兔',
71
+ 349: '坚强', 350: '贴贴', 351: '敲敲', 352: '咦', 353: '拜托', 354: '尊嘟假嘟', 355: '耶', 356: '666', 357: '裂开', 358: '骰子',
72
+ 359: '包剪锤', 360: '亲亲', 361: '狗狗笑哭', 362: '好兄弟', 363: '狗狗可怜', 364: '超级赞', 365: '狗狗生气', 366: '芒狗', 367: '狗狗疑问', 368: '奥特笑哭',
73
+ 369: '彩虹', 370: '祝贺', 371: '冒泡', 372: '气呼呼', 373: '忙', 374: '波波流泪', 375: '超级鼓掌', 376: '跺脚', 377: '嗨', 378: '企鹅笑哭',
74
+ 379: '企鹅流泪', 380: '真棒', 381: '路过', 382: 'emo', 383: '企鹅爱心', 384: '晚安', 385: '太气了', 386: '呜呜呜', 387: '太好笑', 388: '太头疼',
75
+ 389: '太赞了', 390: '太头秃', 391: '太沧桑', 392: '龙年快乐', 393: '新年中龙', 394: '新年大龙', 395: '略略略', 396: '狼狗', 397: '抛媚眼', 398: '超级ok',
76
+ 399: 'tui', 400: '快乐', 401: '超级转圈', 402: '别说话', 403: '出去玩', 404: '闪亮登场', 405: '好运来', 406: '姐是女王', 407: '我听听', 408: '臭美',
77
+ 409: '送你花花', 410: '么么哒', 411: '一起嗨', 412: '开心', 413: '摇起来', 415: '划龙舟', 416: '中龙舟', 417: '大龙舟', 419: '火车', 420: '中火车',
78
+ 421: '大火车', 422: '粽于等到你', 423: '复兴号', 424: '续标识', 425: '求放过', 426: '玩火', 427: '偷感', 428: '收到', 429: '蛇年快乐', 430: '蛇身',
79
+ 431: '蛇尾', 432: '灵蛇献瑞', 450: '撇嘴', 451: '色', 452: '微笑', 453: '发呆', 454: '得意', 455: '害羞', 456: '闭嘴', 457: '睡',
80
+ 458: '我吗', 459: '优雅', 460: '硬撑', 461: '宕机', 462: '无语', 463: '新年快乐', 464: '马上到', 465: '拆红包', 466: '羞羞哒', 467: '摇花手',
81
+ 468: '失眠', 469: '坚毅', 470: '马到成功', 472: '心动', 474: '给你一拳', 475: '干饭', 476: '不是吧', 477: '你懂的', 478: '对的对的', 479: '不对不对',
82
+ 480: '散味儿', 481: '学习', 482: '热化了', 483: '略', 484: '比爱心', 488: '开学大吉', 494: '举杯邀月', 495: '兔来', 496: '阴晴圆缺', 497: '休假了',
83
+ 498: '中!', 499: '观月C位', 500: '秋秋赏月', 501: '秋秋之约', 502: '秋秋泛舟', 503: '秋愿达成', 515: '王者再战'
84
+ };
85
+
86
+ /** face 段 → 【表情:呲牙】;认不出的 id 显示成「表情(128)」而不是空话。 */
87
+ export function faceLabel(id) {
88
+ const raw = String(id ?? '').trim();
89
+ if (raw === '') return '表情';
90
+ const key = Number(raw);
91
+ if (Number.isInteger(key) && FACE_NAMES[key]) return FACE_NAMES[key];
92
+ return `表情(${raw})`;
93
+ }
94
+
47
95
  /**
48
96
  * 消息段 → 可读正文 + 图片 + 引用。@ 我本身从正文里去掉(模型不需要看到"@鱼"),@ 别人保留为"@昵称"。
49
97
  * @returns {{text: string, images: Array<{url?: string, file?: string, summary?: string}>, files: Array<{name: string, path: string, url: string, size: number}>, replyId: string|null}}
@@ -76,7 +124,7 @@ export function segmentsToText(segments, { selfId = '' } = {}) {
76
124
  images.push({ url: d.url, file: d.file, summary: d.summary, sticker: true });
77
125
  parts.push(`【表情${d.summary ? `:${sanitizeName(d.summary, '')}` : ''}】`);
78
126
  break;
79
- case 'face': parts.push('【表情】'); break;
127
+ case 'face': parts.push(`【表情:${faceLabel(d.id)}】`); break;
80
128
  case 'record':
81
129
  records.push({ file: d.file, url: d.url, path: d.path });
82
130
  parts.push('【语音(暂时听不了)】');
package/lib/client.js CHANGED
@@ -349,7 +349,7 @@ window.__ModuleLoader__.load({
349
349
  const [busy, setBusy] = react.useState(false);
350
350
  const [form, setForm, patch] = useForm(data, () => ({
351
351
  enabled: qq.enabled === true, wsUrl: qq.wsUrl ?? "ws://127.0.0.1:3001", accessToken: "", masterQqUin: data.masterQqUin ?? "",
352
- groupAllowList: (qq.groupAllowList ?? []).join(", "), allGroups: qq.allGroups === true, followUpCount: qq.followUpCount ?? 3, followUpSeconds: qq.followUpSeconds ?? 120,
352
+ groupAllowList: (qq.groupAllowList ?? []).join(", "), allGroups: qq.allGroups === true, followUpCount: qq.followUpCount ?? 3, followUpSeconds: qq.followUpSeconds ?? 120, batchMs: qq.batchMs ?? 3000,
353
353
  callNames: (qq.callNames ?? []).join(", "), ij: { ...(qq.interject ?? {}), chance: Math.round((qq.interject?.chance ?? 0.05) * 100), keywords: (qq.interject?.keywords ?? []).join(", ") }
354
354
  }));
355
355
  if (!data || !form) return null;
@@ -360,7 +360,7 @@ window.__ModuleLoader__.load({
360
360
  const res = await api("POST", "/channels/qq-config", {
361
361
  enabled: next.enabled, wsUrl: next.wsUrl, ...(next.accessToken !== "" ? { accessToken: next.accessToken } : {}),
362
362
  masterQqUin: next.masterQqUin, groupAllowList: split(next.groupAllowList), allGroups: next.allGroups === true,
363
- followUpCount: Number(next.followUpCount), followUpSeconds: Number(next.followUpSeconds), callNames: split(next.callNames),
363
+ followUpCount: Number(next.followUpCount), followUpSeconds: Number(next.followUpSeconds), batchMs: Number(next.batchMs), callNames: split(next.callNames),
364
364
  interject: { enabled: next.ij.enabled === true, chance: Number(next.ij.chance) / 100, cooldownMinutes: Number(next.ij.cooldownMinutes), maxPerHour: Number(next.ij.maxPerHour), keywords: split(next.ij.keywords) }
365
365
  });
366
366
  if (res?.ok) { if (next.accessToken !== "") setForm({ ...next, accessToken: "" }); refresh(); } else notify(res?.error ?? "保存失败");
@@ -393,6 +393,8 @@ window.__ModuleLoader__.load({
393
393
  form.enabled ? Row({ title: "点名后接着听" },
394
394
  Num({ min: 0, max: 20, value: form.followUpCount, "aria-label": "条数", onChange: (e) => patch({ followUpCount: e.target.value }), onBlur: () => save() }), Muted("条"),
395
395
  Num({ min: 0, max: 1800, value: form.followUpSeconds, "aria-label": "秒数", onChange: (e) => patch({ followUpSeconds: e.target.value }), onBlur: () => save() }), Muted("秒")) : null,
396
+ form.enabled ? Row({ title: "连着发几条时", desc: "你连着发消息,她等你停手再一起回;0 = 每条都回" },
397
+ Num({ min: 0, max: 15000, step: 500, value: form.batchMs, "aria-label": "等待毫秒", onChange: (e) => patch({ batchMs: e.target.value }), onBlur: () => save({ batchMs: form.batchMs === "" ? 3000 : Number(form.batchMs) }) }), Muted("毫秒")) : null,
396
398
  form.enabled ? Row({ title: "叫这些名字也算点名" }, field("callNames", { placeholder: "人格卡名字" })) : null,
397
399
  form.enabled ? Row({ title: "群里主动插话" }, h(Switch, { label: "群里主动插话", checked: ij.enabled === true, onChange: (v) => save({ ij: { ...ij, enabled: v } }) })) : null,
398
400
  form.enabled && ij.enabled ? Row({ title: "插话概率" }, ijNum("chance", "插话概率", 0, 100), Muted("%"), ijNum("maxPerHour", "每小时最多", 0, 30), Muted("次/小时")) : null,
@@ -424,8 +426,8 @@ window.__ModuleLoader__.load({
424
426
  const [busy, setBusy] = react.useState(false);
425
427
  const [form, , patch] = useForm(data, () => ({
426
428
  masterUid: b.masterUid ?? "", allowList: (b.allowList ?? []).join(", "), links: (b.links ?? []).join("\n"),
427
- perDay: br.perDay ?? 3, videosPerSession: br.videosPerSession ?? 3, searchKeywords: (br.searchKeywords ?? []).join(","), searchPerDay: br.searchPerDay ?? 2,
428
- maxLinks: br.maxLinks ?? 3, subtitleChars: br.subtitleChars ?? 1200, descChars: br.descChars ?? 300, hotCommentChars: br.hotCommentChars ?? 80,
429
+ perDay: br.perDay ?? 3, videosPerSession: br.videosPerSession ?? 3, favoritePerDay: br.favoritePerDay ?? 2, searchKeywords: (br.searchKeywords ?? []).join(","), searchPerDay: br.searchPerDay ?? 2, frames: br.frames === true, framesPerDay: br.framesPerDay ?? 1, framesPerDayChat: br.framesPerDayChat ?? 10,
430
+ maxLinks: br.maxLinks ?? 3, subtitleChars: br.subtitleChars ?? 1200, comment: br.comment === true, commentPerDay: br.commentPerDay ?? 1, descChars: br.descChars ?? 300, hotCommentChars: br.hotCommentChars ?? 80,
429
431
  postPerDay: b.post?.perDay ?? 1, commentsPerHour: b.comments?.perHour ?? 6, commentsReplyChars: b.comments?.replyChars ?? 40
430
432
  }));
431
433
 
@@ -453,8 +455,8 @@ window.__ModuleLoader__.load({
453
455
  };
454
456
  const save = (p = {}) => act("/channels/bili-config", {
455
457
  masterUid: form.masterUid, allowList: split(form.allowList), links: split(form.links, /\n+/), ...(p.top ?? {}),
456
- browse: { perDay: Number(form.perDay), videosPerSession: Number(form.videosPerSession), searchKeywords: split(form.searchKeywords), searchPerDay: Number(form.searchPerDay),
457
- maxLinks: Number(form.maxLinks), subtitleChars: Number(form.subtitleChars), descChars: Number(form.descChars), hotCommentChars: Number(form.hotCommentChars), ...(p.browse ?? {}) },
458
+ browse: { perDay: Number(form.perDay), videosPerSession: Number(form.videosPerSession), favoritePerDay: Number(form.favoritePerDay), searchKeywords: split(form.searchKeywords), searchPerDay: Number(form.searchPerDay), frames: form.frames === true, framesPerDay: Number(form.framesPerDay), framesPerDayChat: Number(form.framesPerDayChat),
459
+ maxLinks: Number(form.maxLinks), subtitleChars: Number(form.subtitleChars), comment: form.comment === true, commentPerDay: Number(form.commentPerDay), descChars: Number(form.descChars), hotCommentChars: Number(form.hotCommentChars), ...(p.browse ?? {}) },
458
460
  post: { perDay: Number(form.postPerDay), ...(p.post ?? {}) },
459
461
  comments: { perHour: Number(form.commentsPerHour), replyChars: Number(form.commentsReplyChars), ...(p.comments ?? {}) }
460
462
  }, null, "保存失败");
@@ -514,12 +516,43 @@ window.__ModuleLoader__.load({
514
516
  Num({ min: 0, max: 12, value: form.perDay, "aria-label": "轮数", onChange: (e) => patch({ perDay: e.target.value }), onBlur: () => save() }), Muted("轮 ×"),
515
517
  Num({ min: 1, max: 6, value: form.videosPerSession, "aria-label": "每轮条数", onChange: (e) => patch({ videosPerSession: e.target.value }), onBlur: () => save() }), Muted("条")) : null,
516
518
  br.enabled !== false ? Row({ title: "喜欢就点赞" }, sw("喜欢就点赞", br.like === true, (v) => save({ browse: { like: v } }))) : null,
517
- br.enabled !== false ? Row({ title: "刷到有意思的发给你", desc: "带链接和感受,发到你的 QQ 私聊;一天最多 2 条" }, sw("刷到有意思的发给你", br.share !== false, (v) => save({ browse: { share: v } }))) : null,
518
- br.enabled !== false ? Row({ title: "想再看的就收藏", desc: `收进默认收藏夹,一天最多 ${br.favoritePerDay ?? 2} 个;默认关` },
519
- br.favorite ? Muted(`今天 ${br.favoritedToday ?? 0}`) : null,
520
- sw("想再看的就收藏", br.favorite === true, (v) => save({ browse: { favorite: v } }))) : null,
519
+ br.enabled !== false ? Row({ title: "刷到有意思的发给你", desc: "带链接和感受,发到你的 QQ 私聊;多少条受「主动开口」那边的额度管" }, sw("刷到有意思的发给你", br.share !== false, (v) => save({ browse: { share: v } }))) : null,
520
+ br.enabled !== false ? Row({ title: "想再看的就收藏", desc: `收进默认收藏夹,每天最多 ${br.favoritePerDay ?? 2} 个(可改),建议 1–3 个;默认关` },
521
+ br.favorite ? Muted(`今天收藏了 ${br.favoritedToday ?? 0}`) : null,
522
+ sw("想再看的就收藏", br.favorite === true, (v) => save({ browse: { favorite: v } })),
523
+ br.favorite ? Num({ min: 0, max: 10, value: form.favoritePerDay, "aria-label": "一天最多收藏几个", onChange: (e) => patch({ favoritePerDay: e.target.value }), onBlur: () => save() }) : null,
524
+ br.favorite ? Muted("个/天") : null) : null,
525
+ br.enabled !== false ? Row({ title: "看一眼画面", desc: `抽视频开头 1 分钟的九宫格截图给她看(要用能看图的模型,flash 可以、pro 不行);一条十几秒;默认关` },
526
+ br.frames ? Muted(`今天看了 ${br.framedToday ?? 0}`) : null,
527
+ sw("看一眼画面", br.frames === true, (v) => save({ browse: { frames: v } })),
528
+ br.frames ? Muted("自己刷的") : null,
529
+ br.frames ? Num({ min: 0, max: 5, value: form.framesPerDay, "aria-label": "自己刷每天最多几条", onChange: (e) => patch({ framesPerDay: e.target.value }), onBlur: () => save() }) : null,
530
+ br.frames ? Muted("条/天;") : null,
531
+ br.frames ? Muted("你发链接的") : null,
532
+ br.frames ? Num({ min: 0, max: 30, value: form.framesPerDayChat, "aria-label": "你发链接时每天最多几条", onChange: (e) => patch({ framesPerDayChat: e.target.value }), onBlur: () => save() }) : null,
533
+ br.frames ? Muted(`条/天(今天 ${br.framedTodayChat ?? 0})`) : null) : null,
534
+ br.enabled !== false ? Row({ title: "回一条别人的评论", stack: true, desc: "在她看的视频评论区里挑一条值得搭话的,回一句(楼中楼);不追着聊、不抬杠;默认关" },
535
+ sw("回一条别人的评论", br.commentReply === true, (v) => save({ browse: { commentReply: v } })),
536
+ br.commentReply ? Num({ min: 0, max: 5, value: form.commentReplyPerDay, "aria-label": "一天最多回几条", onChange: (e) => patch({ commentReplyPerDay: e.target.value }), onBlur: () => save() }) : null,
537
+ br.commentReply ? Muted("条/天") : null,
538
+ br.commentReply ? Muted(`(今天回了 ${br.repliedToday ?? 0})`) : null,
539
+ br.commentReply && br.replyLast
540
+ ? h("a", {
541
+ className: "fs-unit",
542
+ style: { display: "block", width: "100%", marginTop: "4px" },
543
+ href: `https://www.bilibili.com/video/${br.replyLast.bvid}?comment_on=1`,
544
+ target: "_blank",
545
+ rel: "noreferrer",
546
+ title: br.replyLast.title ?? ""
547
+ }, `最近一次:回 @${br.replyLast.name || "某人"}「${String(br.replyLast.text ?? "").slice(0, 20)}」`)
548
+ : null) : null,
521
549
  br.enabled !== false ? Row({ title: "也看弹幕", desc: "看视频时顺便看几条弹幕(只看不发)" }, sw("也看弹幕", br.danmaku !== false, (v) => save({ browse: { danmaku: v } }))) : null,
522
- br.enabled !== false ? Row({ title: "你发视频给她时读多细", desc: "你在聊天里发 B站 链接,她先看一遍再回;字数是节选上限,不写 0 就行" },
550
+ br.enabled !== false ? Row({ title: "看完留一句", desc: `真看完、有感觉的视频,在评论区留一句(不超过 25 字,只写不追回复),一天最多 ${br.commentPerDay ?? 1} 条;默认关` },
551
+ br.comment ? Muted(`今天留了 ${br.commentedToday ?? 0}`) : null,
552
+ sw("看完留一句", br.comment === true, (v) => save({ browse: { comment: v } })),
553
+ br.comment ? Num({ min: 0, max: 5, value: form.commentPerDay, "aria-label": "一天最多几条", onChange: (e) => patch({ commentPerDay: e.target.value }), onBlur: () => save() }) : null,
554
+ br.comment ? Muted("条/天") : null) : null,
555
+ br.enabled !== false ? Row({ title: "你发视频给她时读多细", stack: true, desc: "你在聊天里发 B站 链接,她先看一遍再回;四个数字都可以改,字数是节选上限,不写 0 就行" },
523
556
  Num({ min: 1, max: 5, value: form.maxLinks, "aria-label": "最多读几条", onChange: (e) => patch({ maxLinks: e.target.value }), onBlur: () => save() }), Muted("条"),
524
557
  Num({ min: 0, max: 3000, step: 100, value: form.subtitleChars, "aria-label": "字幕字数", onChange: (e) => patch({ subtitleChars: e.target.value }), onBlur: () => save() }), Muted("字字幕,"),
525
558
  Num({ min: 0, max: 1000, step: 50, value: form.descChars, "aria-label": "简介字数", onChange: (e) => patch({ descChars: e.target.value }), onBlur: () => save() }), Muted("字简介,"),
@@ -21,7 +21,15 @@ export function biliConfigSchema(z) {
21
21
  share: z.boolean().default(true), // 刷到特别想分享的,给主人发链接 + 感受(走主动开口的限制)
22
22
  favorite: z.boolean().default(false), // 想再看的收藏到默认收藏夹
23
23
  favoritePerDay: z.number().default(2),
24
+ commentReply: z.boolean().default(false),
25
+ commentReplyPerDay: z.number().default(1),
26
+ hotComments: z.number().default(6),
24
27
  danmaku: z.boolean().default(true), // 看视频时也看几条弹幕(只读)
28
+ comment: z.boolean().default(false), // 看完真喜欢的视频,在底下留一句(只写,不追回复)
29
+ commentPerDay: z.number().default(1), // 一天最多留几条
30
+ frames: z.boolean().default(false), // 看一眼画面(抽开头一段拼九宫格,需要能看图的模型)
31
+ framesPerDay: z.number().default(1), // 自己刷的视频一天最多抽几条
32
+ framesPerDayChat: z.number().default(10), // 你发链接过来时一天最多抽几条(另记一本)
25
33
  search: z.boolean().default(false), // 按关键词站内搜一下,当作刷视频的又一个来源(只读)
26
34
  searchKeywords: z.array(z.string()).default([]), // 想引导就填几个词;空 = 让她自己按最近看过的提
27
35
  searchPerDay: z.number().default(2), // 一天最多搜几次
@@ -13,7 +13,7 @@ export function fishSoulConfigSchema(z) {
13
13
  allGroups: z.boolean().default(false), // 显式放开:true = 所有群(仍然只听点名)
14
14
  followUpCount: z.number().default(3),
15
15
  followUpSeconds: z.number().default(120),
16
- batchMs: z.number().default(1500),
16
+ batchMs: z.number().default(3000),
17
17
  replyWindowMinutes: z.number().default(10),
18
18
  callNames: z.array(z.string()).default([]), // 叫这些名字也算点名;空 = 人格卡名字(≥2 个字才算)
19
19
  interject: z.object({ // 群里没点名时自己插一句(默认关)
@@ -98,13 +98,47 @@ export const mediaMethods = {
98
98
  this.enqueue(m, person, cfg);
99
99
  },
100
100
 
101
+ /**
102
+ * 主人私聊发链接、并且明确让她去评论区说一句 → 真的发一条。
103
+ * 只有主人能触发;不受「刷视频时自己留一句」开关限制,但共用每天的留评额度。
104
+ */
105
+ async commentOnRequest(bvid) {
106
+ if (this.browser === null || typeof this.browser.gather !== 'function') return [];
107
+ try {
108
+ const cfg = this.browser.cfg?.() ?? {};
109
+ const used = Number(this.browser.commentedToday?.() ?? 0);
110
+ const cap = Number(cfg.commentPerDay ?? 1);
111
+ if (used >= cap) return ['【你让她去评论区说一句,但今天留评的额度已经用完了】'];
112
+ const v = await this.browser.gather(bvid);
113
+ if (!v?.aid) return ['【你让她去评论区说一句,但没拿到这条视频的评论入口】'];
114
+ const said = await this.browser.commentOn(v, '主人让我看完之后,在这条视频下面说一句。');
115
+ if (!said) return ['【你让她去评论区说一句,她没发成】'];
116
+ const title = String(v.title ?? '').slice(0, 30);
117
+ return ['【你让她去评论区说一句,她已经在《' + title + '》下面发了:' + said + '】'];
118
+ } catch (error) {
119
+ this.log('warn', '[fish-soul] 按主人要求发评论失败:' + String(error instanceof Error ? error.message : error));
120
+ return ['【想去评论区说一句,但没成功】'];
121
+ }
122
+ },
123
+
101
124
  /** 消息里的 B站 / 抖音视频 → 简短介绍(没开或取不到就什么都不加)。 */
102
125
  async videoNotes(items) {
103
126
  const text = items.map(({ m }) => [m.text, ...(m.links ?? [])].join(' ')).join(' ');
104
127
  const jobs = [];
128
+ const askFromMaster = /(评论|留言|冒个泡|说一句|留一句|回一句)/.test(String(text)) && items.every(({ m }) => m.chatType === 'private') && String(this.masterQq?.() ?? '') !== '' && items.some(({ m }) => String(m.userId) === String(this.masterQq?.() ?? ''));
129
+ let askBvid = '';
105
130
  if (this.browser !== null && this.biliConfig().enabled) {
106
131
  const refs = findVideoRefs(text);
107
- if (refs.bvids.length > 0 || refs.shorts.length > 0) jobs.push(this.withTimeout(this.browser.describe(refs), 8000));
132
+ if (refs.bvids.length > 0 || refs.shorts.length > 0) {
133
+ if (askFromMaster) {
134
+ if (refs.bvids.length > 0) askBvid = refs.bvids[0];
135
+ else if (refs.shorts.length > 0) {
136
+ // b23.tv 短链:得先换成 BV 号才能发评论
137
+ try { askBvid = String(await this.browser?.client?.resolveShort?.(refs.shorts[0]) ?? ''); } catch { askBvid = ''; }
138
+ }
139
+ }
140
+ jobs.push(this.withTimeout(this.browser.describe(refs), 8000));
141
+ }
108
142
  }
109
143
  if (this.douyinBrowser !== null && this.douyinConfig().enabled) {
110
144
  const refs = findDouyinRefs(text);
@@ -112,7 +146,9 @@ export const mediaMethods = {
112
146
  if (refs.ids.length > 0 || refs.shorts.length > 0) jobs.push(this.withTimeout(this.douyinBrowser.describe(refs), 30_000));
113
147
  }
114
148
  if (jobs.length === 0) return [];
115
- return (await Promise.all(jobs)).flat();
149
+ const notes = (await Promise.all(jobs)).flat();
150
+ if (askBvid !== '') notes.push(...await this.withTimeout(this.commentOnRequest(askBvid), 20_000));
151
+ return notes;
116
152
  },
117
153
 
118
154
  async withTimeout(p, ms) {
package/lib/gateway.js CHANGED
@@ -202,7 +202,7 @@ export class ChatGateway {
202
202
  allGroups: q.allGroups === true,
203
203
  followUpCount: Number.isFinite(Number(q.followUpCount)) ? Number(q.followUpCount) : 3,
204
204
  followUpSeconds: Number.isFinite(Number(q.followUpSeconds)) ? Number(q.followUpSeconds) : 120,
205
- batchMs: Number.isFinite(Number(q.batchMs)) ? Number(q.batchMs) : 1500,
205
+ batchMs: Number.isFinite(Number(q.batchMs)) ? Number(q.batchMs) : 3000,
206
206
  replyWindowMinutes: Number.isFinite(Number(q.replyWindowMinutes)) ? Number(q.replyWindowMinutes) : 10,
207
207
  callNames: Array.isArray(q.callNames) ? q.callNames.map((x) => String(x).trim()).filter(Boolean) : [],
208
208
  interject: interjectConfig(q.interject)
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ import { moodProfile, makeMoodJudge, DiaryWriter, debugRecall, behaviorLines } f
31
31
  import { SnowLumaChannel } from './channels/snowluma.js';
32
32
  import { BiliClient, BiliChannel } from './channels/bilibili.js';
33
33
  import { BiliBrowser } from './browse.js';
34
+ import { videoFrames } from './video-frames.js';
34
35
  import { BiliSocial } from './social.js';
35
36
  import { DouyinSocial } from './douyin-social.js';
36
37
  import { DouyinHost, DouyinChannel } from './channels/douyin.js';
@@ -245,7 +246,10 @@ async function startup(ctx, config, { log, gate }) {
245
246
  const browser = new BiliBrowser({
246
247
  db, clock, client: biliClient, getConfig: biliCfg, callLlm: llmRouter.bound('browse'), worker, log,
247
248
  share: (x) => life.queueShare(x), note: (k, x) => gateway?.note(k, x),
248
- persona: () => ({ name: persona.name, brief: personaBrief() })
249
+ persona: () => ({ name: persona.name, brief: personaBrief() }),
250
+ // 抽帧看画面(bili.browse.frames 开才用):一条失败不影响她看完这条视频
251
+ // 超时 60s:CDN 卡住时 ffmpeg 永久挂起会拖死整轮 watch(),到点 kill 退回纯文字
252
+ frames: (v) => videoFrames(biliClient, { bvid: v.bvid, cid: v.cid, signal: AbortSignal.timeout(60_000) })
249
253
  });
250
254
  // 自己发动态 / 回评论区(默认都关)
251
255
  const biliSocial = new BiliSocial({
package/lib/llm.js CHANGED
@@ -41,6 +41,19 @@ export function estimateTokens(text) {
41
41
  /** 推理模型的输出额度(思考 + 正文) */
42
42
  const ROOMY_TOKENS = 8000;
43
43
 
44
+ /**
45
+ * 把提示词和图片拼成 DSH 的 ContentBlock[]。
46
+ * 图片块形状沿用插件收图时的写法:{ type: 'image', data(base64), mediaType, name }。
47
+ */
48
+ export function buildContent(prompt, images = []) {
49
+ const content = [{ type: 'text', text: prompt }];
50
+ for (const img of images) {
51
+ if (!img?.data) continue;
52
+ content.push({ type: 'image', data: img.data, mediaType: img.mediaType ?? 'image/jpeg', ...(img.name ? { name: img.name } : {}) });
53
+ }
54
+ return content;
55
+ }
56
+
44
57
  export class LlmRouter {
45
58
  /**
46
59
  * @param {object} deps
@@ -110,7 +123,26 @@ export class LlmRouter {
110
123
  * @param {string} prompt
111
124
  * @param {{signal?: AbortSignal, maxTokens?: number, feature?: string}} [opts]
112
125
  */
113
- async call(prompt, { signal, maxTokens = 600, feature = 'other', thinkFallback = false, _target, _tried } = {}) {
126
+ /**
127
+ * 把图片先交给宿主的附件库(admitPromptContent),拿到 { type:'image', attachment } 这种
128
+ * 真正能进模型的块。裸 base64 直接塞 messages 有的通道会静默丢掉——图就白抽了。
129
+ * 附件库不可用或拒收:退回纯文字,不让整次调用失败。
130
+ */
131
+ async promptParts(prompt, images = [], feature = 'other') {
132
+ const content = buildContent(prompt, images);
133
+ if (images.length === 0) return content;
134
+ try {
135
+ const att = this.ctx.get?.('attachments') ?? this.ctx.attachments;
136
+ if (att && typeof att.admitPromptContent === 'function') return await att.admitPromptContent(content);
137
+ this.log('warn', `[fish-soul] ${feature}:宿主没有附件库,图片只能丢掉`);
138
+ } catch (error) {
139
+ const msg = String(error instanceof Error ? error.message : error).slice(0, 160);
140
+ this.log('warn', `[fish-soul] ${feature}:图片没过附件库(${msg}),这次只看文字`);
141
+ }
142
+ return [{ type: 'text', text: prompt }];
143
+ }
144
+
145
+ async call(prompt, { signal, maxTokens = 600, feature = 'other', thinkFallback = false, images = [], _target, _tried } = {}) {
114
146
  const llm = this.llm();
115
147
  if (llm === null) return null;
116
148
  const f = FEATURE_IDS.has(feature) ? feature : 'other';
@@ -134,7 +166,7 @@ export class LlmRouter {
134
166
  for await (const chunk of llm.stream({
135
167
  ...(target.provider !== undefined ? { provider: target.provider, model: target.model } : {}),
136
168
  // DSH 的 RequestMessage.content 是内容块数组(ContentBlock[]),不是字符串
137
- messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
169
+ messages: [{ role: 'user', content: await this.promptParts(prompt, images, f) }],
138
170
  maxTokens: budget,
139
171
  temperature: 0.7,
140
172
  ...(signal ? { signal } : {})
@@ -166,6 +198,11 @@ export class LlmRouter {
166
198
  } catch (error) {
167
199
  this.lastError = String(error instanceof Error ? error.message : error).slice(0, 200);
168
200
  this.record(f, target.model, { failed: true, inputTokens: estimateTokens(prompt), outputTokens: 0, estimated: true });
201
+ // 带图调用失败(有的通道/模型不吃 image 块):先退回纯文字重试一次
202
+ if (images.length > 0) {
203
+ this.log('warn', `[fish-soul] ${f}:带图调用失败(${this.lastError}),退回纯文字重试`);
204
+ return this.call(prompt, { signal, maxTokens, feature, thinkFallback, _target: target, _tried: tried });
205
+ }
169
206
  // 按功能选的模型报错(比如模型下线了):换 DSH 默认模型试一次
170
207
  return this.fallback(prompt, { signal, maxTokens, feature, thinkFallback, tried: tried.add(key) });
171
208
  }
package/lib/panel.css CHANGED
@@ -41,7 +41,7 @@
41
41
  .fs .fs-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary)}
42
42
  .fs-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
43
43
  .fs-input.num{flex:none;width:64px;padding:0 8px;text-align:center;font-variant-numeric:tabular-nums}
44
- .fs-input.wide,.fs-row.stack .fs-input{flex:1 1 auto;width:100%}
44
+ .fs-input.wide,.fs-row.stack .fs-input:not(.num){flex:1 1 auto;width:100%}
45
45
  textarea.fs-input{height:auto;min-height:60px;padding:6px 12px;resize:vertical}
46
46
  .fs-affix{position:relative;display:flex;flex:0 1 260px;width:260px;min-width:0}
47
47
  .fs-affix .fs-input{flex:1 1 auto;width:100%;padding-right:36px}
package/lib/routes.js CHANGED
@@ -95,8 +95,21 @@ export function registerRoutes(ctx, deps) {
95
95
  share: br.share !== false,
96
96
  favorite: br.favorite === true,
97
97
  favoritePerDay: br.favoritePerDay ?? 2,
98
+ commentReply: br.commentReply === true,
99
+ commentReplyPerDay: br.commentReplyPerDay ?? 1,
100
+ hotComments: br.hotComments ?? 6,
101
+ repliedToday: browser?.repliedToday?.() ?? 0,
102
+ replyLast: browser?.recentReply?.() ?? null,
98
103
  favoritedToday: browser?.favoritedToday?.() ?? 0,
99
104
  danmaku: br.danmaku !== false,
105
+ comment: br.comment === true,
106
+ commentPerDay: br.commentPerDay ?? 1,
107
+ commentedToday: browser?.commentedToday?.() ?? 0,
108
+ frames: br.frames === true,
109
+ framesPerDay: br.framesPerDay ?? 1,
110
+ framedToday: browser?.framedToday?.() ?? 0,
111
+ framesPerDayChat: Number(br.framesPerDayChat ?? 10),
112
+ framedTodayChat: browser?.framedTodayChat?.() ?? 0,
100
113
  search: br.search === true,
101
114
  searchKeywords: Array.isArray(br.searchKeywords) ? br.searchKeywords : [],
102
115
  searchPerDay: br.searchPerDay ?? 2,
@@ -456,6 +469,7 @@ export function registerRoutes(ctx, deps) {
456
469
  allGroups: q.allGroups === true,
457
470
  followUpCount: q.followUpCount ?? 3,
458
471
  followUpSeconds: q.followUpSeconds ?? 120,
472
+ batchMs: q.batchMs ?? 3000,
459
473
  callNames: Array.isArray(q.callNames) ? q.callNames : [],
460
474
  interject: interjectConfig(q.interject),
461
475
  stickers: stickerConfig(cfg.stickers),
@@ -498,6 +512,7 @@ export function registerRoutes(ctx, deps) {
498
512
  allGroups: body.allGroups === undefined ? cur.allGroups === true : body.allGroups === true,
499
513
  followUpCount: Math.min(20, Math.max(0, Math.floor(Number(body.followUpCount ?? cur.followUpCount ?? 3)) || 0)),
500
514
  followUpSeconds: Math.min(1800, Math.max(0, Math.floor(Number(body.followUpSeconds ?? cur.followUpSeconds ?? 120)) || 0)),
515
+ batchMs: Math.min(15000, Math.max(0, Math.floor(Number(body.batchMs ?? cur.batchMs ?? 3000)) || 0)),
501
516
  callNames: Array.isArray(body.callNames) ? body.callNames.map((x) => String(x).trim()).filter(Boolean).slice(0, 10) : (cur.callNames ?? []),
502
517
  interject: interjectConfig(body.interject === undefined ? cur.interject : { ...(cur.interject ?? {}), ...body.interject })
503
518
  };
@@ -571,7 +586,15 @@ export function registerRoutes(ctx, deps) {
571
586
  share: br.share === undefined ? cb.share !== false : br.share === true,
572
587
  favorite: br.favorite === undefined ? cb.favorite === true : br.favorite === true,
573
588
  favoritePerDay: clamp(br.favoritePerDay, cb.favoritePerDay ?? 2, 0, 10),
589
+ commentReply: br.commentReply === undefined ? cb.commentReply === true : br.commentReply === true,
590
+ commentReplyPerDay: clamp(br.commentReplyPerDay, cb.commentReplyPerDay ?? 1, 0, 5),
591
+ hotComments: clamp(br.hotComments, cb.hotComments ?? 6, 0, 10),
574
592
  danmaku: br.danmaku === undefined ? cb.danmaku !== false : br.danmaku === true,
593
+ comment: br.comment === undefined ? cb.comment === true : br.comment === true,
594
+ commentPerDay: clamp(br.commentPerDay, cb.commentPerDay ?? 1, 0, 5),
595
+ frames: br.frames === undefined ? cb.frames === true : br.frames === true,
596
+ framesPerDay: clamp(br.framesPerDay, cb.framesPerDay ?? 1, 0, 5),
597
+ framesPerDayChat: clamp(br.framesPerDayChat, cb.framesPerDayChat ?? 10, 0, 30),
575
598
  search: br.search === undefined ? cb.search === true : br.search === true,
576
599
  searchKeywords: (Array.isArray(br.searchKeywords) ? br.searchKeywords : (cb.searchKeywords ?? []))
577
600
  .map((w) => String(w ?? '').trim().slice(0, 30)).filter((w) => w !== '').slice(0, 20),
package/lib/social.js CHANGED
@@ -260,7 +260,11 @@ export class SocialBase {
260
260
  it.kind === 'at'
261
261
  ? `有人在${this.label}评论区 @ 了你,在别人的${where}${it.title ? `《${oneLine(it.title, 60)}》` : ''}下面(不是你的${where},回的话大家都看得到)。`
262
262
  : `有人在${this.label}评论区回复了你。在你的${where}${it.title ? `《${oneLine(it.title, 60)}》` : ''}下面。`,
263
- it.target ? `ta 回的是你说的:${oneLine(it.target, 120)}` : '',
263
+ it.target
264
+ ? it.kind === 'at'
265
+ ? `ta 回的是这条评论:${oneLine(it.target, 160)}`
266
+ : `ta 回的是你说的:${oneLine(it.target, 120)}`
267
+ : '',
264
268
  `评论的人:${oneLine(it.name, 30) || '某人'}${this.whoIs(it, master)}`,
265
269
  `ta 说:${oneLine(it.content, 200)}`,
266
270
  it.read ? `视频是:《${oneLine(it.read.title, 60)}》${it.read.tname ? `(${it.read.tname})` : ''}` : '',
@@ -0,0 +1,97 @@
1
+ // 抽帧:让 ffmpeg 直接读 B站 的播放地址,只解开头一段,抓 9 张拼成九宫格。
2
+ // 不整段下载(一条 20 分钟的视频上百 MB),实测 6.7 秒 / 136KB 出图。
3
+ import { spawn } from 'node:child_process';
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir, tmpdir } from 'node:os';
7
+
8
+ /** 九宫格:每 step 秒一格,每格缩放宽 480,拼 3×3。 */
9
+ export function buildFfmpegArgs({ url, out, seconds = 60, step = 6, cols = 3, rows = 3, skip = 3 }) {
10
+ return [
11
+ '-hide_banner', '-loglevel', 'error', '-y',
12
+ // 跳过开头几秒(很多视频前 3 秒是台标/黑屏),只解 seconds 秒
13
+ '-ss', String(skip), '-t', String(seconds),
14
+ '-user_agent', 'Mozilla/5.0', '-referer', 'https://www.bilibili.com/',
15
+ '-i', url,
16
+ '-vf', `fps=1/${step},scale=480:-2,tile=${cols}x${rows}`,
17
+ '-frames:v', '1', '-q:v', '4', out
18
+ ];
19
+ }
20
+
21
+ /** 找 ffmpeg:环境变量 → PATH → winget 装的那个。找不到返回 ''。 */
22
+ export function resolveFfmpeg() {
23
+ const env = String(process.env.FFMPEG_PATH ?? '').trim();
24
+ if (env !== '' && existsSync(env)) return env;
25
+ const win = process.platform === 'win32';
26
+ const name = win ? 'ffmpeg.exe' : 'ffmpeg';
27
+ for (const d of String(process.env.PATH ?? '').split(win ? ';' : ':').filter(Boolean)) {
28
+ const p = join(d, name);
29
+ if (existsSync(p)) return p;
30
+ }
31
+ if (win) {
32
+ const base = join(process.env.LOCALAPPDATA ?? homedir(), 'Microsoft', 'WinGet', 'Packages');
33
+ try {
34
+ for (const dir of readdirSync(base)) {
35
+ if (!/ffmpeg/i.test(dir)) continue;
36
+ const root = join(base, dir);
37
+ for (const sub of readdirSync(root)) {
38
+ const p = join(root, sub, 'bin', name);
39
+ if (existsSync(p)) return p;
40
+ }
41
+ }
42
+ } catch { /* 没有 winget 目录 */ }
43
+ }
44
+ return '';
45
+ }
46
+
47
+ /** 取 720p 单文件播放地址(fnval=1 不要 DASH)。client.call 返回整包 { code, data }。 */
48
+ export async function playUrl(client, bvid, cid, qn = 80) {
49
+ const json = await client.call('api', '/x/player/playurl', {
50
+ params: { bvid, cid, qn, fnval: 1, platform: 'pc', high_quality: 1 },
51
+ referer: 'https://www.bilibili.com/'
52
+ });
53
+ const durl = (json?.data ?? json)?.durl?.[0];
54
+ if (!durl?.url) throw new Error('没有拿到播放地址');
55
+ return { url: String(durl.url).replace(/&amp;/g, '&'), size: Number(durl.size ?? 0) };
56
+ }
57
+
58
+ function run(ffmpeg, args, signal) {
59
+ return new Promise((resolve, reject) => {
60
+ const p = spawn(ffmpeg, args, { windowsHide: true, stdio: 'ignore' });
61
+ const onAbort = () => { try { p.kill(); } catch { /* 已经退了 */ } };
62
+ signal?.addEventListener?.('abort', onAbort, { once: true });
63
+ p.on('error', (e) => { signal?.removeEventListener?.('abort', onAbort); reject(e); });
64
+ p.on('close', (code) => {
65
+ signal?.removeEventListener?.('abort', onAbort);
66
+ if (signal?.aborted) return reject(new Error('已取消'));
67
+ code === 0 ? resolve() : reject(new Error(`ffmpeg 退出码 ${code}`));
68
+ });
69
+ });
70
+ }
71
+
72
+ /** 拼一张九宫格写到 out。 */
73
+ export async function grabMontage(url, out, { ffmpeg = resolveFfmpeg(), seconds, step, signal } = {}) {
74
+ if (ffmpeg === '') throw new Error('找不到 ffmpeg');
75
+ await run(ffmpeg, buildFfmpegArgs({ url, out, seconds, step }), signal);
76
+ if (!existsSync(out)) throw new Error('抽帧没出图');
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * 一步到位:取地址 → ffmpeg 远程抽帧 → 读成 base64 → 删临时文件。
82
+ * 返回 { data, mediaType, name, bytes };任何一步失败都抛错,调用方自己决定要不要重试。
83
+ */
84
+ export async function videoFrames(client, { bvid, cid, dir, seconds = 60, step = 6, signal } = {}) {
85
+ if (!bvid || !cid) throw new Error('缺 bvid / cid');
86
+ const work = dir ?? join(tmpdir(), 'fish-soul-frames');
87
+ mkdirSync(work, { recursive: true });
88
+ const out = join(work, `${bvid}-${Date.now()}.jpg`);
89
+ try {
90
+ const { url } = await playUrl(client, bvid, cid);
91
+ await grabMontage(url, out, { seconds, step, signal });
92
+ const bytes = readFileSync(out);
93
+ return { data: bytes.toString('base64'), mediaType: 'image/jpeg', name: `${bvid}-frames.jpg`, bytes: bytes.length };
94
+ } finally {
95
+ try { rmSync(out, { force: true }); } catch { /* 删不掉就留着 */ }
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.13.9",
4
- "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.13.9)",
3
+ "version": "0.14.0",
4
+ "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.14.0)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "files": [
@@ -62,7 +62,7 @@
62
62
  }
63
63
  },
64
64
  "scripts": {
65
- "test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/client-lint.mjs",
65
+ "test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/detail-config.mjs && node test/qq-send-any-image.mjs && node test/qq-read-attachment.mjs && node test/qq-batch-window.mjs && node test/comment-video.mjs && node test/frames.mjs && node test/face-name.mjs && node test/comment-reply.mjs && node test/chat-comment.mjs && node test/at-target.mjs && node test/client-lint.mjs",
66
66
  "eval": "node test/eval/persona-eval.mjs"
67
67
  }
68
68
  }