dsh-fish-soul 0.11.0 → 0.11.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.
@@ -225,6 +225,17 @@ window.__ModuleLoader__.load({
225
225
  h("span", null, `${s.count} 条`),
226
226
  opts.self[s.key] ? null : Input({ value: opts.qq[s.key] ?? "", placeholder: "QQ 号", inputMode: "numeric", "aria-label": `${s.name} 的 QQ 号`, style: { flex: "0 0 120px", width: "120px" },
227
227
  onChange: (e) => setOpts({ ...opts, qq: { ...opts.qq, [s.key]: e.target.value } }) }))))) : null,
228
+ pv.coverage && pv.coverage.droppedChunks > 0 ? Row({
229
+ title: `${pv.coverage.truncatedPersons} 个人的较早聊天这次不会导入`,
230
+ desc: `每人只提炼最近 ${pv.coverage.chunksPerPerson} 段(每段约 ${pv.coverage.chunkChars} 字)。` +
231
+ `跳过 ${pv.coverage.droppedChunks} 段、约 ${Math.round(pv.coverage.droppedChars / 1000) / 10} 万字` +
232
+ (pv.coverage.droppedFromMs ? `,最早到 ${new Date(pv.coverage.droppedFromMs).toLocaleDateString()}` : "") + "。" +
233
+ (pv.coverage.topTruncated ?? []).map((t) => `${t.name} 跳过 ${t.droppedChunks} 段${t.keptFromMs ? `(从 ${new Date(t.keptFromMs).toLocaleDateString()} 起导入)` : ""}`).join(";")
234
+ }, Tag("部分导入", "warning")) : null,
235
+ pv.coverage && Number(opts.maxCalls) < pv.coverage.keptChunks ? Row({
236
+ title: `调用上限 ${opts.maxCalls} 次,还有 ${pv.coverage.keptChunks - Number(opts.maxCalls)} 段会跳过`,
237
+ desc: `要全部导入需要 ${pv.coverage.keptChunks} 次(最多 500);不够时每人从最近的往前轮流分`
238
+ }, Tag("额度不够", "warning")) : null,
228
239
  Row({ title: "最多调用模型", desc: pv.memoryCount && !pv.messageCount ? undefined : `${mapped} 个人能导入;没填 QQ 号的人跳过` },
229
240
  Num({ min: 0, max: 500, value: opts.maxCalls, "aria-label": "最多调用模型", onChange: (e) => setOpts({ ...opts, maxCalls: e.target.value }) }), Muted("次"),
230
241
  Button({ variant: "ghost", onClick: () => { setPv(null); setOpts(null); } }, "取消"),
package/lib/gateway.js CHANGED
@@ -31,6 +31,7 @@ import { parseCollect } from './stickers.js';
31
31
  import { UNHEARD } from './voice.js';
32
32
  import { typingDelayMs, interjectScore, interjectConfig, platformOf, splitChannelId, sessionChannelOf, service } from './gateway-util.js';
33
33
  import { mediaMethods } from './gateway-media.js';
34
+ import { Outbox, classifySendError } from './outbox.js';
34
35
  import { behaviorMethods } from './gateway-behavior.js';
35
36
  import { 该收这条, 提取正文, 提取图块, 拆成消息段, 抠正文图 } from './channels/vendor/qq-bridge.js';
36
37
 
@@ -97,6 +98,20 @@ export class ChatGateway {
97
98
  this.douyinBrowser = douyinBrowser;
98
99
  this.canvasConvert = canvasConvert;
99
100
  this.soul = soul;
101
+ /** v0.11.1:QQ 没发出去的回复排队重发(落在 soul.db,重启也不丢) */
102
+ this.outbox = new Outbox({
103
+ db, channel, log: this.log,
104
+ windowMs: () => (Number(this.qqConfig().replyWindowMinutes) || 10) * 60_000,
105
+ onSent: (id, item) => {
106
+ this.rememberOwn(id, item.text);
107
+ this.note('sent', { channelId: item.channel_id, title: item.title, text: item.text || '[图片]', reason: `重发成功(第 ${item.attempts + 1} 次)` });
108
+ },
109
+ onGiveUp: (item, reason) => {
110
+ this.note('failed', { channelId: item.channel_id, title: item.title, text: item.text || '[图片]', reason });
111
+ this.log('warn', `[fish-soul] 放弃重发到 ${item.title || item.channel_id}:${reason}`);
112
+ }
113
+ });
114
+ if (this.outbox.size() > 0) this.outbox.schedule(3000);
100
115
  /** v0.7:语音(听 / 说)、收藏表情包、学群里的说法(都可以没有) */
101
116
  this.voice = voice;
102
117
  this.stickers = stickers;
@@ -258,6 +273,7 @@ export class ChatGateway {
258
273
 
259
274
  stop() {
260
275
  this.stopped = true;
276
+ this.outbox?.stop();
261
277
  for (const b of this.batches.values()) clearTimeout(b.timer);
262
278
  this.batches.clear();
263
279
  for (const w of this.waiters.values()) { clearTimeout(w.timer); w.resolve(false); }
@@ -1020,15 +1036,33 @@ export class ChatGateway {
1020
1036
  // 语音消息不能带引用
1021
1037
  if (replyTo && messages[0][0]?.type !== 'record') messages[0].unshift({ type: 'reply', data: { id: String(replyTo) } });
1022
1038
  let allOk = true;
1039
+ // 这个聊天里还有没重发完的:新回复排到后面,顺序不乱
1040
+ let queued = this.outbox.hasQueued(row.channel_id);
1041
+ let queuedCount = 0;
1023
1042
  await this.sleep(this.firstDelay(profile));
1024
1043
  for (const [i, segs] of messages.entries()) {
1044
+ const text = voiced[i] ? `[语音] ${paragraphs[i]}` : paragraphs[i] ?? '[图片]';
1045
+ if (queued) {
1046
+ this.outbox.enqueue({ channelId: row.channel_id, title: row.title, segs, text });
1047
+ queuedCount++;
1048
+ continue;
1049
+ }
1025
1050
  if (i > 0) await this.sleep(Math.min(6000, typingDelayMs(paragraphs[i] ?? '', this.random) * profile.delay));
1026
1051
  try {
1027
1052
  const id = await this.channel.send(row.channel_id, segs);
1028
- this.rememberOwn(id, voiced[i] ? `[语音] ${paragraphs[i]}` : paragraphs[i] ?? '[图片]');
1053
+ this.rememberOwn(id, text);
1029
1054
  } catch (error) {
1030
- allOk = false;
1031
- this.log('error', `[fish-soul] 发到 ${row.title || row.channel_id} 失败:${String(error instanceof Error ? error.message : error)}`);
1055
+ const msg = String(error instanceof Error ? error.message : error);
1056
+ if (classifySendError(error) === 'retry') {
1057
+ // 肯定没发出去:这条和后面的都排队重发
1058
+ this.outbox.enqueue({ channelId: row.channel_id, title: row.title, segs, text, error: msg });
1059
+ queued = true;
1060
+ queuedCount++;
1061
+ this.log('warn', `[fish-soul] 发到 ${row.title || row.channel_id} 没成功(${msg}),排队重发`);
1062
+ } else {
1063
+ allOk = false;
1064
+ this.log('error', `[fish-soul] 发到 ${row.title || row.channel_id} 失败(${msg}),可能已经发出去了,不重发`);
1065
+ }
1032
1066
  }
1033
1067
  }
1034
1068
  if (isGroup) {
@@ -1043,7 +1077,8 @@ export class ChatGateway {
1043
1077
  this.soul.journalOut?.(row.channel_id, paragraphs.join('\n'));
1044
1078
  this.slang?.observe(row.channel_id, '', paragraphs.join(' '), { self: true }); // 学说法时当上下文,不从她自己的话里学
1045
1079
  }
1046
- this.note(allOk ? 'sent' : 'failed', { channelId: row.channel_id, title: row.title, text: paragraphs.map((p, i) => (voiced[i] ? `🔊${p}` : p)).join(' / ') || '[图片]', reason: allOk ? (interject ? '插了一句' : '') : '有消息没发出去', memories });
1080
+ const reason = !allOk ? '有消息没发出去' : queuedCount > 0 ? `${queuedCount} 条暂时没发出去,排队重发中` : interject ? '插了一句' : '';
1081
+ this.note(allOk && queuedCount === 0 ? 'sent' : 'failed', { channelId: row.channel_id, title: row.title, text: paragraphs.map((p, i) => (voiced[i] ? `🔊${p}` : p)).join(' / ') || '[图片]', reason, memories });
1047
1082
  return allOk;
1048
1083
  }
1049
1084
  }
@@ -328,7 +328,14 @@ export function buildPersonChunks(parsed, { selfKeys, mapping, personaName = '
328
328
  cur.endMs = l.timeMs ?? cur.endMs;
329
329
  }
330
330
  // 只留最近的几段(越近越要紧),按时间顺序交给模型
331
- out.push({ qq: p.qq, name: p.name, messageCount: p.messageCount, firstMs: p.firstMs, chunks: chunks.slice(-CHUNKS_PER_PERSON).map(({ chat, ...rest }) => rest) });
331
+ const kept = chunks.slice(-CHUNKS_PER_PERSON);
332
+ const dropped = chunks.slice(0, Math.max(0, chunks.length - CHUNKS_PER_PERSON));
333
+ out.push({
334
+ qq: p.qq, name: p.name, messageCount: p.messageCount, firstMs: p.firstMs,
335
+ chunks: kept.map(({ chat, ...rest }) => rest),
336
+ // 超出每人段数上限、这次不会交给模型的较早部分(预览里要明说,别让人以为全导进去了)
337
+ dropped: { chunks: dropped.length, chars: dropped.reduce((n, c) => n + c.text.length, 0), fromMs: dropped[0]?.startMs ?? null, toMs: dropped.at(-1)?.endMs ?? null }
338
+ });
332
339
  }
333
340
  return out.sort((a, b) => b.messageCount - a.messageCount);
334
341
  }
@@ -348,6 +355,24 @@ export function capChunks(persons, maxCalls) {
348
355
  return want.map(({ keep, ...p }) => ({ ...p, chunks: keep === 0 ? [] : p.chunks.slice(-keep) })).filter((p) => p.chunks.length > 0);
349
356
  }
350
357
 
358
+ /** 预览用:这次会导入多少、有多少较早的聊天不会交给模型。 */
359
+ export function coverageOf(persons) {
360
+ const truncated = persons.filter((p) => (p.dropped?.chunks ?? 0) > 0);
361
+ const froms = truncated.map((p) => p.dropped.fromMs).filter((t) => t !== null);
362
+ return {
363
+ persons: persons.length,
364
+ chunksPerPerson: CHUNKS_PER_PERSON,
365
+ chunkChars: CHUNK_CHARS,
366
+ keptChunks: persons.reduce((n, p) => n + p.chunks.length, 0),
367
+ droppedChunks: truncated.reduce((n, p) => n + p.dropped.chunks, 0),
368
+ droppedChars: truncated.reduce((n, p) => n + p.dropped.chars, 0),
369
+ droppedFromMs: froms.length > 0 ? Math.min(...froms) : null,
370
+ truncatedPersons: truncated.length,
371
+ topTruncated: [...truncated].sort((a, b) => b.dropped.chunks - a.dropped.chunks).slice(0, 5)
372
+ .map((p) => ({ name: p.name, qq: p.qq, droppedChunks: p.dropped.chunks, keptFromMs: p.chunks[0]?.startMs ?? null }))
373
+ };
374
+ }
375
+
351
376
  function extractPrompt({ personaName, name, chunk }) {
352
377
  const range = [fmtDay(chunk.startMs), fmtDay(chunk.endMs)].filter(Boolean);
353
378
  const when = range.length === 0 ? '' : range[0] === range[1] ? `,${range[0]}` : `,${range[0]} ~ ${range[1]}`;
@@ -421,6 +446,7 @@ export class HistoryImporter {
421
446
  memoryCount: (parsed.memories ?? []).length,
422
447
  speakers: list,
423
448
  estimate: { persons: persons.length, calls: Math.min(DEFAULT_MAX_CALLS, persons.reduce((s, p) => s + p.chunks.length, 0)) },
449
+ coverage: coverageOf(persons),
424
450
  warnings: parsed.warnings ?? []
425
451
  };
426
452
  }
package/lib/index.js CHANGED
@@ -192,7 +192,7 @@ async function startup(ctx, config, { log, gate }) {
192
192
  getConfig: qqCfg,
193
193
  log,
194
194
  onEvent: (event) => { void gateway?.onEvent(event).catch((error) => log('error', `[fish-soul] QQ 消息处理失败:${String(error instanceof Error ? error.message : error)}`)); },
195
- onConnected: () => gateway?.backfill()
195
+ onConnected: () => { void gateway?.outbox.flush({ force: true }); return gateway?.backfill(); }
196
196
  });
197
197
 
198
198
  step(STARTUP_STEPS[3]);
package/lib/outbox.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * outbox.js —— QQ 发送失败的重发队列(v0.11.1)。
3
+ *
4
+ * 模型已经写好的回复,因为 QQ 没连上 / 被接口拒了而没发出去,以前只记一条「发送失败」就丢了。
5
+ * 现在先落到 soul.db 的 outbox 表,按 2s → 10s → 30s → 60s → 120s 退避重发,连上 QQ 时立刻补发;
6
+ * 超过回复时限(replyWindowMinutes)或重试用完就放弃并记进「动态」。
7
+ *
8
+ * 只重发"肯定没发出去"的:QQ 未连接、接口明确拒绝(retcode)。
9
+ * 连接中途断开 / 调用超时时消息可能已经到了对方那里,重发会刷屏,所以不重发,只记一笔。
10
+ * 同一个聊天里有排队的消息时,新的回复也排到后面,保证顺序不乱。
11
+ */
12
+ const BACKOFF_MS = [2000, 10_000, 30_000, 60_000, 120_000];
13
+ const KEEP_DONE_MS = 24 * 3600_000;
14
+
15
+ export const OUTBOX_SQL = `
16
+ CREATE TABLE IF NOT EXISTS outbox (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ channel_id TEXT NOT NULL,
19
+ title TEXT NOT NULL DEFAULT '',
20
+ segs TEXT NOT NULL,
21
+ text TEXT NOT NULL DEFAULT '',
22
+ attempts INTEGER NOT NULL DEFAULT 0,
23
+ next_at INTEGER NOT NULL,
24
+ created_at INTEGER NOT NULL,
25
+ status TEXT NOT NULL DEFAULT 'queued',
26
+ last_error TEXT NOT NULL DEFAULT ''
27
+ );
28
+ CREATE INDEX IF NOT EXISTS idx_outbox_queued ON outbox(status, channel_id, id);
29
+ `;
30
+
31
+ /** 'retry' = 肯定没发出去,可以重发;'unknown' = 可能已经发出去了,不重发。 */
32
+ export function classifySendError(error) {
33
+ const msg = String(error instanceof Error ? error.message : error);
34
+ if (/未连接/.test(msg)) return 'retry';
35
+ if (/retcode/.test(msg)) return 'retry';
36
+ return 'unknown';
37
+ }
38
+
39
+ export class Outbox {
40
+ /**
41
+ * @param {object} deps
42
+ * @param {import('node:sqlite').DatabaseSync} deps.db
43
+ * @param {{ connected?: boolean, send: (channelId: string, segs: object[]) => Promise<string> }} deps.channel
44
+ * @param {() => number} deps.windowMs 超过多久就不再重发(取回复时限)
45
+ * @param {(id: string, item: object) => void} [deps.onSent]
46
+ * @param {(item: object, reason: string) => void} [deps.onGiveUp]
47
+ * @param {(level: string, msg: string) => void} [deps.log]
48
+ * @param {() => number} [deps.now]
49
+ */
50
+ constructor({ db, channel, windowMs, onSent, onGiveUp, log, now }) {
51
+ this.db = db;
52
+ this.channel = channel;
53
+ this.windowMs = windowMs;
54
+ this.onSent = onSent ?? (() => {});
55
+ this.onGiveUp = onGiveUp ?? (() => {});
56
+ this.log = log ?? (() => {});
57
+ this.now = now ?? (() => Date.now());
58
+ this.timer = null;
59
+ this.flushing = null;
60
+ this.stopped = false;
61
+ db.exec(OUTBOX_SQL);
62
+ }
63
+
64
+ hasQueued(channelId) {
65
+ return this.db.prepare("SELECT 1 FROM outbox WHERE status = 'queued' AND channel_id = ? LIMIT 1").get(channelId) !== undefined;
66
+ }
67
+
68
+ size() {
69
+ return Number(this.db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE status = 'queued'").get().n);
70
+ }
71
+
72
+ enqueue({ channelId, title = '', segs, text = '', error = '' }) {
73
+ const now = this.now();
74
+ this.db.prepare(`INSERT INTO outbox (channel_id, title, segs, text, next_at, created_at, last_error)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(channelId, title, JSON.stringify(segs), text, now + BACKOFF_MS[0], now, String(error));
76
+ this.schedule(BACKOFF_MS[0]);
77
+ }
78
+
79
+ schedule(delayMs) {
80
+ if (this.stopped) return;
81
+ if (this.timer !== null) clearTimeout(this.timer);
82
+ this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, Math.max(0, delayMs));
83
+ this.timer.unref?.();
84
+ }
85
+
86
+ /** 把到点的排队消息按聊天、按顺序发出去;某个聊天发失败就停在那里,下一轮再试。 */
87
+ flush({ force = false } = {}) {
88
+ this.flushing ??= this.flushOnce({ force }).finally(() => { this.flushing = null; });
89
+ return this.flushing;
90
+ }
91
+
92
+ async flushOnce({ force }) {
93
+ if (this.stopped) return;
94
+ const now = this.now();
95
+ this.db.prepare("DELETE FROM outbox WHERE status <> 'queued' AND created_at < ?").run(now - KEEP_DONE_MS);
96
+ const rows = this.db.prepare("SELECT * FROM outbox WHERE status = 'queued' ORDER BY id").all();
97
+ const blocked = new Set();
98
+ const mark = this.db.prepare('UPDATE outbox SET status = ?, attempts = ?, next_at = ?, last_error = ? WHERE id = ?');
99
+ for (const row of rows) {
100
+ if (blocked.has(row.channel_id)) continue;
101
+ const item = { ...row, segs: JSON.parse(row.segs) };
102
+ if (now - row.created_at > this.windowMs()) {
103
+ mark.run('expired', row.attempts, row.next_at, row.last_error, row.id);
104
+ this.onGiveUp(item, '超过回复时限,不再重发');
105
+ continue;
106
+ }
107
+ if (!force && row.next_at > now) { blocked.add(row.channel_id); continue; }
108
+ if (this.channel.connected === false) { blocked.add(row.channel_id); continue; }
109
+ try {
110
+ const id = await this.channel.send(row.channel_id, item.segs);
111
+ mark.run('sent', row.attempts + 1, row.next_at, '', row.id);
112
+ this.onSent(id, item);
113
+ } catch (error) {
114
+ const attempts = row.attempts + 1;
115
+ const msg = String(error instanceof Error ? error.message : error);
116
+ if (classifySendError(error) !== 'retry' || attempts >= BACKOFF_MS.length) {
117
+ mark.run('gave_up', attempts, row.next_at, msg, row.id);
118
+ this.onGiveUp(item, attempts >= BACKOFF_MS.length ? `重发 ${attempts} 次都没成功:${msg}` : `重发时${msg},可能已经发出去了,不再重发`);
119
+ continue;
120
+ }
121
+ mark.run('queued', attempts, now + BACKOFF_MS[attempts], msg, row.id);
122
+ blocked.add(row.channel_id);
123
+ }
124
+ }
125
+ const next = this.db.prepare("SELECT MIN(next_at) AS t FROM outbox WHERE status = 'queued'").get().t;
126
+ if (next !== null && next !== undefined) this.schedule(Number(next) - this.now());
127
+ }
128
+
129
+ stop() {
130
+ this.stopped = true;
131
+ if (this.timer !== null) clearTimeout(this.timer);
132
+ this.timer = null;
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fish-soul",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -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",
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",
66
66
  "eval": "node test/eval/persona-eval.mjs"
67
67
  }
68
68
  }