dsh-fish-soul 0.13.9 → 0.14.1
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 +1 -1
- package/lib/browse.js +206 -6
- package/lib/channels/bilibili.js +51 -1
- package/lib/channels/onebot.js +49 -1
- package/lib/client.js +44 -11
- package/lib/config-platforms.js +8 -0
- package/lib/config-schema.js +1 -1
- package/lib/db.js +11 -0
- package/lib/gateway-media.js +38 -2
- package/lib/gateway.js +1 -1
- package/lib/index.js +21 -10
- package/lib/llm.js +41 -2
- package/lib/panel.css +1 -1
- package/lib/routes.js +23 -0
- package/lib/social.js +5 -1
- package/lib/video-frames.js +97 -0
- package/package.json +3 -3
package/README.md
CHANGED
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,
|
|
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
|
|
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);
|
package/lib/channels/bilibili.js
CHANGED
|
@@ -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
|
-
|
|
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。 */
|
package/lib/channels/onebot.js
CHANGED
|
@@ -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(
|
|
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
|
|
518
|
-
br.enabled !== false ? Row({ title: "想再看的就收藏", desc:
|
|
519
|
-
br.favorite ? Muted(
|
|
520
|
-
sw("想再看的就收藏", br.favorite === true, (v) => save({ browse: { favorite: v } }))
|
|
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: "
|
|
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("字简介,"),
|
package/lib/config-platforms.js
CHANGED
|
@@ -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), // 一天最多搜几次
|
package/lib/config-schema.js
CHANGED
|
@@ -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(
|
|
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({ // 群里没点名时自己插一句(默认关)
|
package/lib/db.js
CHANGED
|
@@ -400,6 +400,17 @@ export function randomCanonicalId() {
|
|
|
400
400
|
return `p_${Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0')}`;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/** 桌面端 / 主人兜底:拿到 canonical_id='master' 的 person 行,没有就建一个。 */
|
|
404
|
+
export function ensureMasterPerson(db, nowMs) {
|
|
405
|
+
const row = db.prepare("SELECT * FROM person WHERE canonical_id = 'master'").get();
|
|
406
|
+
if (row) return row;
|
|
407
|
+
db.prepare(`
|
|
408
|
+
INSERT INTO person (canonical_id, display_name, relation_stage, interaction_count, first_seen_at, last_seen_at)
|
|
409
|
+
VALUES ('master', '主人', 'close', 0, ?, ?)
|
|
410
|
+
`).run(nowMs, nowMs);
|
|
411
|
+
return db.prepare("SELECT * FROM person WHERE canonical_id = 'master'").get();
|
|
412
|
+
}
|
|
413
|
+
|
|
403
414
|
/**
|
|
404
415
|
* person 原子自动注册(审查 #05):按 qq_uin Upsert。
|
|
405
416
|
* 主人 QQ 固定映射 canonical_id='master'、relation_stage='close'。
|
package/lib/gateway-media.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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) :
|
|
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
|
@@ -14,7 +14,7 @@ import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
|
14
14
|
import { loadHostDeps } from './hostdeps.mjs';
|
|
15
15
|
import {
|
|
16
16
|
openDatabase, configPath, fishSoulDir, resolveOrCreatePerson, resolveOrCreateBiliPerson, resolveOrCreatePlatformPerson,
|
|
17
|
-
findPersonByQq, findPersonByBili, findPersonByDouyin, readVecMeta, getKv, setKv, getProfile
|
|
17
|
+
findPersonByQq, findPersonByBili, findPersonByDouyin, readVecMeta, getKv, setKv, getProfile, ensureMasterPerson
|
|
18
18
|
} from './db.js';
|
|
19
19
|
import { BackupService, applyPendingRestore } from './backup.js';
|
|
20
20
|
import { HistoryImporter } from './history-import.js';
|
|
@@ -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({
|
|
@@ -446,22 +450,28 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
446
450
|
sp = list.find((x) => x.displayName === want || x.userId === want) ??
|
|
447
451
|
list.find((x) => x.displayName.includes(want) || want.includes(x.displayName)) ?? sp;
|
|
448
452
|
}
|
|
453
|
+
const ch = session?.channel ?? 'dsh';
|
|
454
|
+
let ownerId = sp?.ownerId ?? UNKNOWN_OWNER;
|
|
455
|
+
let person = sp?.person ?? null;
|
|
456
|
+
// #57:桌面端(dsh channel)认作主人——DSH 桌面端是主人自己的环境,没有"别人"。
|
|
457
|
+
if (ch === 'dsh' && ownerId === UNKNOWN_OWNER) {
|
|
458
|
+
person = ensureMasterPerson(db, clock.now());
|
|
459
|
+
ownerId = `user:${person.canonical_id}`;
|
|
460
|
+
}
|
|
449
461
|
return {
|
|
450
|
-
ownerId
|
|
451
|
-
person
|
|
462
|
+
ownerId,
|
|
463
|
+
person,
|
|
452
464
|
query: sp?.query ?? '',
|
|
453
|
-
displayName: sp?.displayName ?? '对方',
|
|
454
|
-
channel:
|
|
465
|
+
displayName: person?.display_name ?? sp?.displayName ?? '对方',
|
|
466
|
+
channel: ch,
|
|
455
467
|
channelId: session?.channelId ?? ''
|
|
456
468
|
};
|
|
457
469
|
}
|
|
458
470
|
|
|
459
|
-
const UNKNOWN_VIEW = { ownerId: UNKNOWN_OWNER, person: null, displayName: '对方', channel: 'dsh', channelId: '' };
|
|
460
|
-
|
|
461
471
|
/** 工具调用:按调用所在会话(toolCtx.agent.id)认人;认不出就是 unknown(不读写任何人的记忆)。 */
|
|
462
472
|
function sessionForTool(toolCtx, speakerName) {
|
|
463
473
|
const session = sessionFor(toolCtx?.agent?.id ?? toolCtx?.sessionId);
|
|
464
|
-
return session === undefined ?
|
|
474
|
+
return session === undefined ? speakerView(undefined, speakerName) : speakerView(session, speakerName);
|
|
465
475
|
}
|
|
466
476
|
|
|
467
477
|
const retrievalCfg = () => ({
|
|
@@ -692,10 +702,11 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
692
702
|
});
|
|
693
703
|
if (person !== null) life.noteInteraction(ownerOf(person));
|
|
694
704
|
const groupId = source.groupId ?? source.group_id ?? null;
|
|
705
|
+
// #56:桌面端(person=null)也把消息文本传进去——世界书扫描要用,身份仍是 unknown。
|
|
695
706
|
beginTurn(agentId, {
|
|
696
707
|
channel: person === null ? 'dsh' : (groupId != null ? 'qq-group' : 'qq-private'),
|
|
697
708
|
channelId: person === null ? '' : (groupId != null ? `group:${groupId}` : `private:${qqUin}`),
|
|
698
|
-
speakers:
|
|
709
|
+
speakers: [{ person, userId: qqUin, displayName: person?.display_name ?? '对方', text: messageText(message) }],
|
|
699
710
|
workspacePath: ''
|
|
700
711
|
});
|
|
701
712
|
// #41:别的插件把自己的 QQ 消息转进它建的会话 —— 那个会话不走我们的 create/resume,人格卡没挂上。
|
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,28 @@ export class LlmRouter {
|
|
|
110
123
|
* @param {string} prompt
|
|
111
124
|
* @param {{signal?: AbortSignal, maxTokens?: number, feature?: string}} [opts]
|
|
112
125
|
*/
|
|
113
|
-
|
|
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
|
+
// #58:默认 600 对推理模型太小——光思考就 1000+ token,第一次必然白卷还白烧思考费。
|
|
146
|
+
// 提到 1500 让大多数推理模型的第一轮能容下思考;maxTokens 是上限不是必花,普通模型不受影响。
|
|
147
|
+
async call(prompt, { signal, maxTokens = 1500, feature = 'other', thinkFallback = false, images = [], _target, _tried } = {}) {
|
|
114
148
|
const llm = this.llm();
|
|
115
149
|
if (llm === null) return null;
|
|
116
150
|
const f = FEATURE_IDS.has(feature) ? feature : 'other';
|
|
@@ -134,7 +168,7 @@ export class LlmRouter {
|
|
|
134
168
|
for await (const chunk of llm.stream({
|
|
135
169
|
...(target.provider !== undefined ? { provider: target.provider, model: target.model } : {}),
|
|
136
170
|
// DSH 的 RequestMessage.content 是内容块数组(ContentBlock[]),不是字符串
|
|
137
|
-
messages: [{ role: 'user', content:
|
|
171
|
+
messages: [{ role: 'user', content: await this.promptParts(prompt, images, f) }],
|
|
138
172
|
maxTokens: budget,
|
|
139
173
|
temperature: 0.7,
|
|
140
174
|
...(signal ? { signal } : {})
|
|
@@ -166,6 +200,11 @@ export class LlmRouter {
|
|
|
166
200
|
} catch (error) {
|
|
167
201
|
this.lastError = String(error instanceof Error ? error.message : error).slice(0, 200);
|
|
168
202
|
this.record(f, target.model, { failed: true, inputTokens: estimateTokens(prompt), outputTokens: 0, estimated: true });
|
|
203
|
+
// 带图调用失败(有的通道/模型不吃 image 块):先退回纯文字重试一次
|
|
204
|
+
if (images.length > 0) {
|
|
205
|
+
this.log('warn', `[fish-soul] ${f}:带图调用失败(${this.lastError}),退回纯文字重试`);
|
|
206
|
+
return this.call(prompt, { signal, maxTokens, feature, thinkFallback, _target: target, _tried: tried });
|
|
207
|
+
}
|
|
169
208
|
// 按功能选的模型报错(比如模型下线了):换 DSH 默认模型试一次
|
|
170
209
|
return this.fallback(prompt, { signal, maxTokens, feature, thinkFallback, tried: tried.add(key) });
|
|
171
210
|
}
|
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
|
|
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(/&/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.
|
|
4
|
-
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu ,v0.14.1)",
|
|
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 && node test/dsh-desktop.mjs",
|
|
66
66
|
"eval": "node test/eval/persona-eval.mjs"
|
|
67
67
|
}
|
|
68
68
|
}
|