dsh-fish-soul 0.10.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.
- package/README.md +8 -1
- package/lib/behavior.js +135 -4
- package/lib/client.behavior.js +15 -2
- package/lib/client.js +8 -1
- package/lib/client.soul.js +11 -0
- package/lib/config-schema.js +112 -0
- package/lib/gateway-behavior.js +13 -5
- package/lib/gateway-media.js +65 -0
- package/lib/gateway.js +47 -6
- package/lib/history-import.js +27 -1
- package/lib/hostdeps.mjs +16 -1
- package/lib/index.js +54 -115
- package/lib/outbox.js +134 -0
- package/lib/routes.js +45 -4
- package/lib/voice.js +28 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
|
|
4
4
|
|
|
5
|
-
DSH 插件(单角色 `feiyu`)。版本 **0.
|
|
5
|
+
DSH 插件(单角色 `feiyu`)。版本 **0.11.0**。
|
|
6
6
|
|
|
7
7
|
## 能力
|
|
8
8
|
|
|
@@ -35,6 +35,13 @@ dsh plugin --profile desktop add dsh-fish-soul
|
|
|
35
35
|
|
|
36
36
|
插件注入 `webServer / tools / systemPrompt / llm / agents / workspaceRegistry`,设置页在 DSH 的插件设置里(「人格 / 记忆 / QQ / B站 / 抖音 / 自走 / 模型 / 习惯 / 动态」)。
|
|
37
37
|
|
|
38
|
+
已在 DSH **0.2.1-rc.5** 上验证;更早的 rc 版本未逐一验证,遇到问题请先升级复测。
|
|
39
|
+
|
|
40
|
+
设置页读不到时怎么分辨:
|
|
41
|
+
|
|
42
|
+
- **HTTP 404**:插件后台根本没加载(或宿主没把 `/api/plugins/fish-soul/*` 转给插件)。看宿主日志里有没有 `[fish-soul] 启动闸门已挂`。
|
|
43
|
+
- **503「插件还在启动 / 插件启动失败,挂在第 N 步…」**:插件加载了,但初始化还没走完或中途失败。宿主日志里有对应的 `[fish-soul] 启动失败:挂在第 N/M 步「…」` 和错误堆栈;走完会打 `[fish-soul] 路由已挂`。
|
|
44
|
+
|
|
38
45
|
## 配置与数据
|
|
39
46
|
|
|
40
47
|
`$DSH_HOME` 缺省 `~/.dsh`。
|
package/lib/behavior.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* behavior.js —— v0.9「她的习惯」(纯函数 + 小服务;网关那边的接线在 gateway-behavior.js):
|
|
3
3
|
* - 心情影响行为:今天的基调 × 她对这个人的情绪 → 回复快慢、插话积极性、表情包 / 语音多少;
|
|
4
|
-
* - 去 AI 味:发出去之前去掉"作为 AI / 综上所述 / 希望对你有帮助 / 首先其次最后"
|
|
4
|
+
* - 去 AI 味:发出去之前去掉"作为 AI / 综上所述 / 希望对你有帮助 / 首先其次最后 / 好问题 / 让我们来看看"、括号里的动作描写、
|
|
5
|
+
* markdown 和自己加的说法,太长的截到句末;一段好几句就拆成几条、去掉句尾的句号;提示里也压一句说话方式;
|
|
5
6
|
* - 合并转发:get_forward_msg 展开一层(限条数);
|
|
6
7
|
* - 每个群一段「群设定」(主人写,只在那个群的上下文里出现);
|
|
7
8
|
* - 按聊天选模型:单个聊天 → 私聊 / 群聊默认 → DSH 默认(找不到的提供方跳过);
|
|
@@ -16,6 +17,9 @@ import { recallSync, vectorRanks, excludedMemories } from './retrieval.js';
|
|
|
16
17
|
const num = (x, d, lo, hi) => { const n = Number(x); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
|
|
17
18
|
const bool = (x, d) => (x === undefined || x === null ? d : x === true);
|
|
18
19
|
const r2 = (x) => Math.round(x * 100) / 100;
|
|
20
|
+
/** 「额外要删的说法」:数组或多行文字 → 去空、去重,最多 50 条、每条 ≤100 字。 */
|
|
21
|
+
const extraList = (x) => [...new Set((Array.isArray(x) ? x : typeof x === 'string' ? x.split('\n') : [])
|
|
22
|
+
.map((l) => String(l ?? '').trim()).filter((l) => l !== '' && l.length <= 100))].slice(0, 50);
|
|
19
23
|
const DAY = 86_400_000;
|
|
20
24
|
|
|
21
25
|
/** 设置里的 behavior → 规整后的参数。 */
|
|
@@ -24,7 +28,7 @@ export function behaviorConfig(raw) {
|
|
|
24
28
|
const o = v.outFilter ?? {}; const f = v.forward ?? {}; const ht = v.heat ?? {}; const ia = v.interactions ?? {}; const tm = v.turnMood ?? {}; const d = v.diary ?? {};
|
|
25
29
|
return {
|
|
26
30
|
moodAffects: bool(v.moodAffects, true),
|
|
27
|
-
outFilter: { enabled: bool(o.enabled, true), maxChars: Math.floor(num(o.maxChars, 300, 60, 2000)) },
|
|
31
|
+
outFilter: { enabled: bool(o.enabled, true), maxChars: Math.floor(num(o.maxChars, 300, 60, 2000)), split: bool(o.split, true), extra: extraList(o.extra) },
|
|
28
32
|
forward: { enabled: bool(f.enabled, true), maxLines: Math.floor(num(f.maxLines, 15, 3, 50)) },
|
|
29
33
|
heat: {
|
|
30
34
|
enabled: bool(ht.enabled, true), windowSec: num(ht.windowSec, 120, 30, 900), hotCount: Math.floor(num(ht.hotCount, 6, 3, 50)),
|
|
@@ -84,8 +88,40 @@ const AI_PHRASES = [
|
|
|
84
88
|
[/(?:希望|但愿)(?:以上|这些|我的)?(?:回答|内容|建议|信息|解释)?(?:能|可以)?(?:对你|对您)?有(?:所)?帮助[^。!!\n]*[。!!~~]?/g, '客套收尾'],
|
|
85
89
|
[/(?:如果|若)(?:你|您)(?:还)?有(?:任何|其他|别的)?(?:问题|疑问|需要)[^。!!\n]*[。!!~~]?/g, '客套收尾'],
|
|
86
90
|
[/(?:综上所述|总而言之|总的来说|总结一下|简而言之)[,,::]?\s*/g, '总结腔'],
|
|
87
|
-
[/(?:请注意|需要注意的是|值得注意的是)[,,::]\s*/g, '说明书腔']
|
|
91
|
+
[/(?:请注意|需要注意的是|值得注意的是)[,,::]\s*/g, '说明书腔'],
|
|
92
|
+
// 下面几条参考 Humanizer-zh(github.com/op7418/Humanizer-zh)里聊天场景用得上的
|
|
93
|
+
[/^[ \t]*(?:好问题|问得好|这是(?:一个|个)(?:很|非常)?好(?:的)?问题)[!!,,。.~~]*[ \t]*/gm, '客服腔'],
|
|
94
|
+
[/我(?:完全|非常|很)?(?:能够|能|可以)?理解你(?:现在)?的(?:感受|心情)[,,。!!]?\s*/g, '客服腔'],
|
|
95
|
+
[/(?:接下来|下面)?让我们(?:一起)?(?:来)?(?:看看|看一下|聊聊|分析一下|深入(?:探讨|了解)?)[^,。!?!?::\n]{0,15}[::,,。!!]\s*/g, '起手铺垫'],
|
|
96
|
+
[/^[ \t]*(?:以下是|下面是)[^,。!?!?\n]{0,20}[::][ \t]*/gm, '起手铺垫'],
|
|
97
|
+
[/(?:有(?:任何|什么|其他|别的)?(?:问题|疑问|需要)(?:的话)?[,,]?\s*)?欢迎随时[^。!!\n]*[。!!~~]?/g, '客套收尾'],
|
|
98
|
+
[/有(?:任何|什么|其他|别的)?(?:问题|疑问|需要)(?:的话)?[,,]?\s*(?:随时|尽管)(?:找我|告诉我|问我|联系我|跟我说)[^。!!\n]*[。!!~~]?/g, '客套收尾']
|
|
88
99
|
];
|
|
100
|
+
|
|
101
|
+
// 括号 / 星号里的动作神态描写("(揉了揉眼睛)""*歪头*"):AI 角色扮演味最重的一种(MaiBot 发出去前也会删括号)。
|
|
102
|
+
// 只删像动作描写的:≥4 个汉字、汉字占大头、以动作词开头,或者带"了 / 着"又不是在说"我 / 你"的事。
|
|
103
|
+
// "(笑)(小声)(bushi)(逃)"这种网上常见的短括号、颜文字、带数字英文的补充说明都不动。
|
|
104
|
+
const ACTION_HEAD = /^(?:她|鱼|小鱼)?(?:轻轻|微微|默默|悄悄|偷偷|慢慢|缓缓|小声地?|低声|歪|眨|揉|摸|拍|笑|叹|挠|托|抱|蹭|戳|探|翻|撇|嘟|鼓|眯|伸|扭|撅|捂|捏|晃|摇|点了点|点点|低下|抬起|抬头|低头|转身|转过|凑|靠|躺|趴|打了个|打个|吐了吐|吐吐|咬|舔|皱|瞪|红着|脸红|脸一红|耳朵|尾巴|双手|一边|眼睛|眼神|声音|嘴角|嘟囔|嘀咕|扶额|捂脸|叉腰|耸肩|摊手|托腮)/;
|
|
105
|
+
function looksLikeAction(inner) {
|
|
106
|
+
const s = String(inner ?? '').trim();
|
|
107
|
+
const han = (s.match(/[\u4e00-\u9fff]/g) ?? []).length;
|
|
108
|
+
if (han < 4 || han < [...s].length * 0.7) return false;
|
|
109
|
+
return ACTION_HEAD.test(s) || (/[了着]/.test(s) && !/[我你]/.test(s));
|
|
110
|
+
}
|
|
111
|
+
const PAREN_RE = /[((]([^()()\n]{1,40})[))]/g;
|
|
112
|
+
const STAR_RE = /(?<![*\w])\*([^*\n]{1,40})\*(?![*\w])/g;
|
|
113
|
+
|
|
114
|
+
/** 「额外要删的说法」→ 正则(/…/ 当正则写,别的按字面);写错的正则跳过。 */
|
|
115
|
+
function extraRegexps(extra) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line of Array.isArray(extra) ? extra : []) {
|
|
118
|
+
const m = /^\/(.+)\/([imsu]*)$/.exec(line);
|
|
119
|
+
try {
|
|
120
|
+
out.push(m ? new RegExp(m[1], `${m[2]}g`) : new RegExp(line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'));
|
|
121
|
+
} catch { /* 写错的正则:跳过 */ }
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
89
125
|
const ORDINAL_RE = /(?:^|[。!?!?\n,,]\s*)(首先|其次|然后|再次|最后|第一|第二|第三)[,,、::]\s*/g;
|
|
90
126
|
|
|
91
127
|
/**
|
|
@@ -103,7 +139,7 @@ export function stripSelfName(text, names = []) {
|
|
|
103
139
|
* 去 AI 味(只改写 / 删 / 截短,不撤回):返回 { text, changes }。
|
|
104
140
|
* 改完变成空的(整段都是套话 / AI 自称)返回空字符串,调用方这轮就不发。
|
|
105
141
|
*/
|
|
106
|
-
export function deAi(input, { maxChars = 300 } = {}) {
|
|
142
|
+
export function deAi(input, { maxChars = 300, extra = [] } = {}) {
|
|
107
143
|
const changes = new Set();
|
|
108
144
|
let t = String(input ?? '');
|
|
109
145
|
const before = t;
|
|
@@ -119,10 +155,31 @@ export function deAi(input, { maxChars = 300 } = {}) {
|
|
|
119
155
|
.replace(/^\s*\|(.+)\|\s*$/gm, (_, row) => row.split('|').map((c) => c.trim()).filter(Boolean).join(','))
|
|
120
156
|
.replace(/`([^`\n]+)`/g, '$1');
|
|
121
157
|
if (t !== before) changes.add('markdown');
|
|
158
|
+
// 动作描写:括号里的整个删;星号里的像动作就删,不像就只去掉星号
|
|
159
|
+
{
|
|
160
|
+
const next = t.replace(PAREN_RE, (m, inner) => (looksLikeAction(inner) ? '' : m))
|
|
161
|
+
.replace(STAR_RE, (m, inner) => (looksLikeAction(inner) ? '' : inner));
|
|
162
|
+
if (next !== t) {
|
|
163
|
+
if (next.replace(STAR_RE, '$1') !== t.replace(STAR_RE, '$1')) changes.add('动作描写');
|
|
164
|
+
else changes.add('markdown');
|
|
165
|
+
t = next;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
122
168
|
for (const [re, label] of AI_PHRASES) {
|
|
123
169
|
const next = t.replace(re, '');
|
|
124
170
|
if (next !== t) { changes.add(label); t = next; }
|
|
125
171
|
}
|
|
172
|
+
for (const re of extraRegexps(extra)) {
|
|
173
|
+
const next = t.replace(re, '');
|
|
174
|
+
if (next !== t) { changes.add('自定义'); t = next; }
|
|
175
|
+
}
|
|
176
|
+
// 破折号当万能连接(一条里 ≥2 个):换成逗号;只有一个的不动
|
|
177
|
+
if ((t.match(/——/g) ?? []).length >= 2) {
|
|
178
|
+
t = t.replace(/\s*——+\s*/g, ',');
|
|
179
|
+
changes.add('破折号');
|
|
180
|
+
}
|
|
181
|
+
// 删完留下的标点残渣:开头的逗号、连着的逗号、逗号后面紧跟句末标点
|
|
182
|
+
t = t.replace(/[,,]\s*(?=[,,。!?!?~~…])/g, '').replace(/[ \t]{2,}/g, ' ');
|
|
126
183
|
const ordinals = t.match(ORDINAL_RE) ?? [];
|
|
127
184
|
if (ordinals.length >= 2) {
|
|
128
185
|
t = t.replace(ORDINAL_RE, (m, w) => m.slice(0, m.indexOf(w)));
|
|
@@ -156,6 +213,75 @@ export function deAi(input, { maxChars = 300 } = {}) {
|
|
|
156
213
|
return { text: t, changes: [...changes] };
|
|
157
214
|
}
|
|
158
215
|
|
|
216
|
+
// ───────────────────────── 拆成几条 ─────────────────────────
|
|
217
|
+
|
|
218
|
+
const OPENERS = { '「': '」', '『': '』', '“': '”', '‘': '’', '(': ')', '(': ')', '《': '》', '【': '】' };
|
|
219
|
+
const CLOSERS = new Set(Object.values(OPENERS));
|
|
220
|
+
const END_PUNCT = /[。!?!?~~…]/;
|
|
221
|
+
|
|
222
|
+
/** 一段话 → 句子的 [起, 止) 位置;引号 / 括号里不断。 */
|
|
223
|
+
function sentenceRanges(p) {
|
|
224
|
+
const chars = [...p];
|
|
225
|
+
const out = [];
|
|
226
|
+
let start = 0; let depth = 0; let ascii = false;
|
|
227
|
+
for (let i = 0; i < chars.length; i++) {
|
|
228
|
+
const c = chars[i];
|
|
229
|
+
if (c === '"') { ascii = !ascii; continue; }
|
|
230
|
+
if (OPENERS[c]) { depth++; continue; }
|
|
231
|
+
if (CLOSERS.has(c)) { depth = Math.max(0, depth - 1); continue; }
|
|
232
|
+
if (depth > 0 || ascii || !END_PUNCT.test(c)) continue;
|
|
233
|
+
let j = i;
|
|
234
|
+
while (j + 1 < chars.length && END_PUNCT.test(chars[j + 1])) j++;
|
|
235
|
+
out.push([start, j + 1]);
|
|
236
|
+
start = j + 1; i = j;
|
|
237
|
+
}
|
|
238
|
+
if (chars.slice(start).join('').trim() !== '') out.push([start, chars.length]);
|
|
239
|
+
return out.map(([a, b]) => chars.slice(a, b).join('').trim()).filter((x) => x !== '');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 去掉句尾单独的一个"。"("。。。"、"…"、问号感叹号都留着)。 */
|
|
243
|
+
const dropDot = (s) => s.replace(/(?<![。.])。$/, '');
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 像打字的人那样一句一发(参考 MaiBot / AstrBot 的分段回复):一段里好几句就按句末标点拆成几条,
|
|
247
|
+
* 句尾的"。"去掉。引号 / 括号里不拆;[语音] 段、多行的段不拆;一段最多 maxPer 条、整条回复最多 maxTotal 条;
|
|
248
|
+
* 相邻的句子随机并回去(越短越容易并),每次拆法不一样。返回 { text, changes }(段之间空一行)。
|
|
249
|
+
*/
|
|
250
|
+
export function splitChat(input, { maxPer = 3, maxTotal = 5, random = Math.random } = {}) {
|
|
251
|
+
const paras = String(input ?? '').split(/\n{2,}/).map((p) => p.trim()).filter((p) => p !== '');
|
|
252
|
+
let budget = Math.max(0, maxTotal - paras.length); // 还能多出来几条
|
|
253
|
+
let splitCount = 0;
|
|
254
|
+
const out = [];
|
|
255
|
+
for (const p of paras) {
|
|
256
|
+
if (/^\[语音\]/.test(p) || p.includes('\n')) { out.push(p); continue; }
|
|
257
|
+
let parts = sentenceRanges(p);
|
|
258
|
+
const len = [...p].length;
|
|
259
|
+
if (parts.length >= 2 && budget > 0) {
|
|
260
|
+
const mergeChance = len < 12 ? 0.8 : len < 32 ? 0.4 : 0.3;
|
|
261
|
+
const merged = [parts[0]];
|
|
262
|
+
for (const s of parts.slice(1)) {
|
|
263
|
+
if (random() < mergeChance) merged[merged.length - 1] += s;
|
|
264
|
+
else merged.push(s);
|
|
265
|
+
}
|
|
266
|
+
// 太多:均匀并成 limit 组
|
|
267
|
+
const limit = Math.min(maxPer, 1 + budget);
|
|
268
|
+
if (merged.length > limit) {
|
|
269
|
+
const groups = [];
|
|
270
|
+
let i = 0;
|
|
271
|
+
for (let g = 0; g < limit; g++) {
|
|
272
|
+
const size = Math.ceil((merged.length - i) / (limit - g));
|
|
273
|
+
groups.push(merged.slice(i, i + size).join(''));
|
|
274
|
+
i += size;
|
|
275
|
+
}
|
|
276
|
+
parts = groups;
|
|
277
|
+
} else parts = merged;
|
|
278
|
+
} else parts = [p];
|
|
279
|
+
if (parts.length > 1) { splitCount += parts.length - 1; budget -= parts.length - 1; }
|
|
280
|
+
out.push(...parts.map(dropDot).filter((x) => x.trim() !== ''));
|
|
281
|
+
}
|
|
282
|
+
return { text: out.join('\n\n'), changes: splitCount > 0 ? [`拆成 ${out.length} 条`] : [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
159
285
|
// ───────────────────────── 合并转发 ─────────────────────────
|
|
160
286
|
|
|
161
287
|
export const FORWARD_MARK = '【合并转发(暂时看不了)】';
|
|
@@ -478,6 +604,11 @@ export function behaviorLines({ db, getConfig, session, known, tone, now, diary,
|
|
|
478
604
|
const own = moodStyle(moods, p);
|
|
479
605
|
if (p.hint) out.push(`[你现在的状态] ${p.hint}——体现在回复的快慢、长短和语气上,别直接说出来。${own ? `${own}。` : ''}`);
|
|
480
606
|
}
|
|
607
|
+
// 去 AI 味:发出去以后再删只能删漏网的,先在提示里压一压(参考 MaiBot 的默认说话风格)
|
|
608
|
+
if (cfg.outFilter.enabled && session.channel !== 'dsh') {
|
|
609
|
+
out.push('[说话方式] 像平时打字聊天:简短、口语,可以省略主语、可以倒装,一次说一两件事;不滥用比喻、不讲大道理、不客套;' +
|
|
610
|
+
'别用括号或星号写动作神态(比如“(揉了揉眼睛)”),别用破折号、冒号、列点。');
|
|
611
|
+
}
|
|
481
612
|
if (!inGroup && known.length === 1 && diary) {
|
|
482
613
|
const topics = diary.topicsFor(known[0].ownerId, now);
|
|
483
614
|
if (topics.length > 0) out.push(`[昨晚日记里想跟 ${known[0].displayName} 聊的] ${topics.join(';')}(合适就自然地提,不合适就算了)`);
|
package/lib/client.behavior.js
CHANGED
|
@@ -26,6 +26,15 @@ window.__ModuleLoader__.load({
|
|
|
26
26
|
return ui.Num({ value: v, min, max, step, "aria-label": label, onChange: (e) => setV(e.target.value), onBlur: () => { if (v !== String(value) && v !== "") onSave(Number(v)); } });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** 失焦才保存的多行列表(一行一个) */
|
|
30
|
+
function ExtraField({ value, onSave, ui }) {
|
|
31
|
+
const joined = (value ?? []).join("\n");
|
|
32
|
+
const [v, setV] = react.useState(joined);
|
|
33
|
+
react.useEffect(() => { setV(joined); }, [joined]);
|
|
34
|
+
return ui.Area({ value: v, rows: 3, maxLength: 3000, "aria-label": "额外要删的说法", placeholder: "比如:\n说实话吧,\n/宝子们?/",
|
|
35
|
+
onChange: (e) => setV(e.target.value), onBlur: () => { if (v.trim() !== joined.trim()) onSave(v.split("\n").map((l) => l.trim()).filter(Boolean)); } });
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
/** 心情和说话 + QQ 小习惯 */
|
|
30
39
|
function Habits({ cfg, save, ui }) {
|
|
31
40
|
const { h, Group, Row, Switch, Muted } = ui;
|
|
@@ -39,10 +48,14 @@ window.__ModuleLoader__.load({
|
|
|
39
48
|
h(Switch, { label: "每轮判断心情", checked: cfg.turnMood.enabled, onChange: (v) => save({ turnMood: { enabled: v } }) })),
|
|
40
49
|
cfg.turnMood.enabled ? Row({ title: "把握够多少才改", desc: "模型给的把握低于这个就不改情绪" },
|
|
41
50
|
h(NumField, { ui, value: pct(cfg.turnMood.minConfidence), min: 0, max: 100, step: 5, label: "把握门槛", onSave: (n) => save({ turnMood: { minConfidence: n / 100 } }) }), Muted("%")) : null,
|
|
42
|
-
Row({ title: "去 AI 味", desc: "发出去之前去掉\"作为 AI /
|
|
51
|
+
Row({ title: "去 AI 味", desc: "发出去之前去掉\"作为 AI / 希望对你有帮助 / 好问题 / 让我们来看看\"、括号里的动作描写(\"(揉了揉眼睛)\")和 markdown,太长的截到句末;也会提醒她别这么说" },
|
|
43
52
|
h(Switch, { label: "去 AI 味", checked: cfg.outFilter.enabled, onChange: (v) => save({ outFilter: { enabled: v } }) })),
|
|
44
53
|
cfg.outFilter.enabled ? Row({ title: "一次最多说多少字" },
|
|
45
|
-
h(NumField, { ui, value: cfg.outFilter.maxChars, min: 60, max: 2000, step: 10, label: "最多字数", onSave: (n) => save({ outFilter: { maxChars: n } }) }), Muted("字")) : null
|
|
54
|
+
h(NumField, { ui, value: cfg.outFilter.maxChars, min: 60, max: 2000, step: 10, label: "最多字数", onSave: (n) => save({ outFilter: { maxChars: n } }) }), Muted("字")) : null,
|
|
55
|
+
cfg.outFilter.enabled ? Row({ title: "自动拆成几条", desc: "一段里好几句时按句号拆开发(一段最多 3 条、每次拆法不一样),句尾的句号去掉;问号、感叹号、省略号留着" },
|
|
56
|
+
h(Switch, { label: "自动拆成几条", checked: cfg.outFilter.split, onChange: (v) => save({ outFilter: { split: v } }) })) : null,
|
|
57
|
+
cfg.outFilter.enabled ? Row({ title: "额外要删的说法", desc: "一行一个,发出去之前删掉;写成 /正则/ 也行。删了什么在「动态」里能看到", stack: true },
|
|
58
|
+
h(ExtraField, { ui, value: cfg.outFilter.extra, onSave: (list) => save({ outFilter: { extra: list } }) })) : null),
|
|
46
59
|
Group({ title: "QQ 小习惯" },
|
|
47
60
|
Row({ title: "看合并转发", desc: "展开一层,最多看前几条" },
|
|
48
61
|
h(Switch, { label: "看合并转发", checked: cfg.forward.enabled, onChange: (v) => save({ forward: { enabled: v } }) })),
|
package/lib/client.js
CHANGED
|
@@ -36,7 +36,14 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
headers: { "content-type": "application/json" },
|
|
37
37
|
...(body !== undefined ? { body: JSON.stringify(body) } : {})
|
|
38
38
|
});
|
|
39
|
-
|
|
39
|
+
const data = await response.json().catch(() => null);
|
|
40
|
+
if (response.status === 503 && data && (data.starting || data.failed)) {
|
|
41
|
+
const at = data.step ? `第 ${data.stepIndex}/${data.stepTotal} 步「${data.step}」` : "";
|
|
42
|
+
return { ok: false, error: data.failed ? `插件启动失败${at ? ",挂在" + at : ""}:${data.error ?? "未知原因"}(详见宿主日志)` : `插件还在启动${at ? "(" + at + ")" : ""},稍等再刷新` };
|
|
43
|
+
}
|
|
44
|
+
if (data) return data;
|
|
45
|
+
if (response.status === 404) return { ok: false, error: "HTTP 404:插件后台没加载,或宿主没把请求转给插件。请看宿主日志里有没有「[fish-soul] 启动闸门已挂」" };
|
|
46
|
+
return { ok: false, error: `HTTP ${response.status}` };
|
|
40
47
|
} catch (error) {
|
|
41
48
|
return { ok: false, error: String(error instanceof Error ? error.message : error) };
|
|
42
49
|
}
|
package/lib/client.soul.js
CHANGED
|
@@ -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); } }, "取消"),
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 插件 Config 的结构(从 index.js 拆出来,免得那边太长;B站 / 抖音那部分在 config-platforms.js)
|
|
2
|
+
import { biliConfigSchema, douyinConfigSchema } from './config-platforms.js';
|
|
3
|
+
import { DEFAULT_DAILY_MAX } from './life.js';
|
|
4
|
+
|
|
5
|
+
export function fishSoulConfigSchema(z) {
|
|
6
|
+
return z.object({
|
|
7
|
+
masterQqUin: z.string().default(''),
|
|
8
|
+
qq: z.object({
|
|
9
|
+
enabled: z.boolean().default(false),
|
|
10
|
+
wsUrl: z.string().default('ws://127.0.0.1:3001'), // SnowLuma OneBot 11 正向 WS(Universal)
|
|
11
|
+
accessToken: z.string().default(''),
|
|
12
|
+
groupAllowList: z.array(z.string()).default([]), // 空 = 所有群(仍然只听点名)
|
|
13
|
+
followUpCount: z.number().default(3),
|
|
14
|
+
followUpSeconds: z.number().default(120),
|
|
15
|
+
batchMs: z.number().default(1500),
|
|
16
|
+
replyWindowMinutes: z.number().default(10),
|
|
17
|
+
callNames: z.array(z.string()).default([]), // 叫这些名字也算点名;空 = 人格卡名字(≥2 个字才算)
|
|
18
|
+
interject: z.object({ // 群里没点名时自己插一句(默认关)
|
|
19
|
+
enabled: z.boolean().default(false),
|
|
20
|
+
chance: z.number().default(0.05),
|
|
21
|
+
cooldownMinutes: z.number().default(15),
|
|
22
|
+
maxPerHour: z.number().default(3),
|
|
23
|
+
keywords: z.array(z.string()).default([])
|
|
24
|
+
}).default({})
|
|
25
|
+
}).default({}),
|
|
26
|
+
backup: z.object({
|
|
27
|
+
auto: z.boolean().default(true), // 每天自动备份 soul.db
|
|
28
|
+
keep: z.number().default(7)
|
|
29
|
+
}).default({}),
|
|
30
|
+
// v0.7:按功能选模型({ provider, model },空 = DSH 默认模型)+ 单价(元 / 百万 token,只用来估花费)
|
|
31
|
+
models: z.dict(z.any()).default({}),
|
|
32
|
+
modelPrices: z.dict(z.any()).default({}),
|
|
33
|
+
voice: z.object({ // 语音:听 QQ 语音、偶尔发语音(听 / 说可以各配一套地址和 key;空 = 沿用下面的 baseURL/apiKey)
|
|
34
|
+
enabled: z.boolean().default(false),
|
|
35
|
+
baseURL: z.string().default(''), // 听说共用(老配置);ttsBaseURL / sttBaseURL 优先
|
|
36
|
+
apiKey: z.string().default(''),
|
|
37
|
+
sttBaseURL: z.string().default(''), // 听(ASR)单独用的地址,空 = 用上面的
|
|
38
|
+
sttApiKey: z.string().default(''),
|
|
39
|
+
ttsBaseURL: z.string().default(''), // 说(TTS)单独用的地址,空 = 用上面的
|
|
40
|
+
ttsApiKey: z.string().default(''),
|
|
41
|
+
sttModel: z.string().default(''),
|
|
42
|
+
ttsModel: z.string().default(''),
|
|
43
|
+
ttsVoice: z.string().default(''),
|
|
44
|
+
speak: z.string().default('mirror'), // never / mirror(对方发语音时可以回语音)/ sometimes
|
|
45
|
+
maxChars: z.number().default(60),
|
|
46
|
+
ttsPerDay: z.number().default(30),
|
|
47
|
+
sttPerDay: z.number().default(200)
|
|
48
|
+
}).default({}),
|
|
49
|
+
stickers: z.object({
|
|
50
|
+
collect: z.boolean().default(true), // 她觉得好玩的表情包存进 _stickers
|
|
51
|
+
perDay: z.number().default(10)
|
|
52
|
+
}).default({}),
|
|
53
|
+
slang: z.object({
|
|
54
|
+
enabled: z.boolean().default(true), // 学群里的梗 / 说话风格
|
|
55
|
+
minLines: z.number().default(60),
|
|
56
|
+
intervalHours: z.number().default(6),
|
|
57
|
+
expressions: z.boolean().default(true), // 学「什么场合怎么说」(只学别人的话、不带人名)
|
|
58
|
+
privateChats: z.boolean().default(true) // 好友私聊也学(只在那个私聊里用)
|
|
59
|
+
}).default({}),
|
|
60
|
+
tools: z.object({
|
|
61
|
+
web: z.object({ // 让她能上网查东西(默认关):DSH 自带的 web_search,或自己填的搜索 API key
|
|
62
|
+
enabled: z.boolean().default(false),
|
|
63
|
+
perDay: z.number().default(20),
|
|
64
|
+
provider: z.string().default('host'), // host | bocha | tavily | brave | searxng
|
|
65
|
+
apiKeys: z.dict(z.string()).default({}), // 每个搜索服务各存一个 key,切换不丢
|
|
66
|
+
baseURL: z.string().default('') // SearXNG 地址
|
|
67
|
+
}).default({})
|
|
68
|
+
}).default({}),
|
|
69
|
+
onboarding: z.object({ dismissed: z.boolean().default(false) }).default({}),
|
|
70
|
+
memory: z.object({ // v0.8 聊完自动记:一段聊天空下来 idleMinutes 分钟(或攒够 maxLines 句)就整理一次
|
|
71
|
+
autoExtract: z.boolean().default(true),
|
|
72
|
+
idleMinutes: z.number().default(10),
|
|
73
|
+
maxLines: z.number().default(40)
|
|
74
|
+
}).default({}),
|
|
75
|
+
growth: z.object({ // 她慢慢长出来的变化:聊完整理时攒证据,够了才生效(人格页可看 / 删 / 清空)
|
|
76
|
+
enabled: z.boolean().default(true),
|
|
77
|
+
autoApply: z.boolean().default(true) // false = 证据够了也先等主人确认
|
|
78
|
+
}).default({}),
|
|
79
|
+
relationship: z.object({ // 每个关系阶段怎么说话(空 = 内置说法)
|
|
80
|
+
styles: z.dict(z.string()).default({})
|
|
81
|
+
}).default({}),
|
|
82
|
+
bili: biliConfigSchema(z), // B站:登录凭据不在这里(存 $DSH_HOME/fish-soul/bili-auth.json)
|
|
83
|
+
douyin: douyinConfigSchema(z), // 抖音:登录态在专用浏览器的 profile 里,不在这里
|
|
84
|
+
sessions: z.object({
|
|
85
|
+
workspacePath: z.string().default('') // 空 = $DSH_HOME/fish-soul/workspace(专用工作区"鱼")
|
|
86
|
+
}).default({}),
|
|
87
|
+
embedding: z.object({
|
|
88
|
+
enabled: z.boolean().default(true),
|
|
89
|
+
baseURL: z.string().default(''),
|
|
90
|
+
apiKey: z.string().default(''),
|
|
91
|
+
model: z.string().default(''),
|
|
92
|
+
timeoutMs: z.number().default(800)
|
|
93
|
+
}).default({}),
|
|
94
|
+
retrieval: z.object({
|
|
95
|
+
fusionMethod: z.string().default('rrf'),
|
|
96
|
+
topK: z.number().default(5),
|
|
97
|
+
tokenBudget: z.number().default(1200)
|
|
98
|
+
}).default({}),
|
|
99
|
+
proactive: z.object({
|
|
100
|
+
enabled: z.boolean().default(true),
|
|
101
|
+
dailyMaxCount: z.number().default(DEFAULT_DAILY_MAX),
|
|
102
|
+
activeHours: z.array(z.number()).default([8, 24]),
|
|
103
|
+
idleTriggerHours: z.number().default(6),
|
|
104
|
+
shareMaxPerDay: z.number().default(2), // 刷到想分享的视频,一天最多主动发几条
|
|
105
|
+
targets: z.object({
|
|
106
|
+
masterCanonicalId: z.string().default('user:master'),
|
|
107
|
+
masterPrivateChannelId: z.string().default(''),
|
|
108
|
+
selfGroupChannelIds: z.array(z.string()).default([])
|
|
109
|
+
}).default({})
|
|
110
|
+
}).default({})
|
|
111
|
+
});
|
|
112
|
+
}
|
package/lib/gateway-behavior.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 心情 → 回复快慢 / 插话 / 表情包和语音、去 AI 味、展开合并转发、群里冷热、戳回去 / 表情回应 / 正在输入、
|
|
4
4
|
* 按聊天选模型、每轮判断心情。所有 OneBot 扩展接口都是"试一下":不支持就记住、以后不再调。
|
|
5
5
|
*/
|
|
6
|
-
import { behaviorConfig, moodProfile, firstDelayMs, deAi, stripSelfName, forwardLines, GroupHeat, resolveChatModel, FORWARD_MARK, NEUTRAL_MOOD } from './behavior.js';
|
|
6
|
+
import { behaviorConfig, moodProfile, firstDelayMs, deAi, splitChat, stripSelfName, forwardLines, GroupHeat, resolveChatModel, FORWARD_MARK, NEUTRAL_MOOD } from './behavior.js';
|
|
7
7
|
import { service } from './gateway-util.js';
|
|
8
8
|
|
|
9
9
|
/** 表情回应用的 QQ 表情 id:赞 / OK / 微笑;开心时爱心 / 笑哭;难过时流泪;生气时不回应 */
|
|
@@ -135,16 +135,24 @@ export const behaviorMethods = {
|
|
|
135
135
|
if (jobs.length > 0) await this.withTimeout(Promise.all(jobs), 6000);
|
|
136
136
|
},
|
|
137
137
|
|
|
138
|
-
/** 发出去之前去 AI
|
|
138
|
+
/** 发出去之前去 AI 味(+ 拆成几条);整段都是套话就返回 ''(这轮不发)。 */
|
|
139
139
|
cleanOut(text, row) {
|
|
140
140
|
// 自己的名字前缀("鱼:…")不管开没开去 AI 味都去掉
|
|
141
141
|
const name = String(this.soul.personaName?.() ?? '').trim();
|
|
142
142
|
text = stripSelfName(text, [name, '鱼', '小鱼', 'feiyu']);
|
|
143
143
|
const cfg = this.behavior().outFilter;
|
|
144
144
|
if (!cfg.enabled || String(text ?? '').trim() === '') return text;
|
|
145
|
-
const r = deAi(text, { maxChars: cfg.maxChars });
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
const r = deAi(text, { maxChars: cfg.maxChars, extra: cfg.extra });
|
|
146
|
+
const changes = [...r.changes];
|
|
147
|
+
let out = r.text;
|
|
148
|
+
// 一段好几句就拆成几条、去掉句尾的句号(单独的开关)
|
|
149
|
+
if (cfg.split && out.trim() !== '') {
|
|
150
|
+
const s = splitChat(out, { random: this.random });
|
|
151
|
+
out = s.text;
|
|
152
|
+
changes.push(...s.changes);
|
|
153
|
+
}
|
|
154
|
+
if (changes.length > 0) this.note('filtered', { channelId: row.channel_id, title: row.title, text, reason: `去 AI 味:${changes.join('、')}` });
|
|
155
|
+
return out;
|
|
148
156
|
},
|
|
149
157
|
|
|
150
158
|
/** 心情不好时:表情包按概率不发(只管 _stickers 里的),语音按概率改成打字。 */
|
package/lib/gateway-media.js
CHANGED
|
@@ -18,6 +18,8 @@ const MAX_INBOUND_IMAGES = 4;
|
|
|
18
18
|
const MAX_PARAGRAPHS = 5;
|
|
19
19
|
const MAX_DM_IMAGES = 2;
|
|
20
20
|
const DOUYIN_EMOJI_RE = /^\[([\u4e00-\u9fa5A-Za-z0-9]{1,8})\]$/;
|
|
21
|
+
const VOICE_TAG_RE = /^\s*\[语音\]\s*/;
|
|
22
|
+
const platformName = (channelId) => (String(channelId).startsWith('bili:') ? 'B站' : '抖音');
|
|
21
23
|
|
|
22
24
|
export const mediaMethods = {
|
|
23
25
|
/**
|
|
@@ -220,6 +222,69 @@ export const mediaMethods = {
|
|
|
220
222
|
},
|
|
221
223
|
|
|
222
224
|
/** B站私信:文字按空行拆条,图片(≤2 张)跟在最后。 */
|
|
225
|
+
/**
|
|
226
|
+
* 抖音 / B站私信发不了语音:主人在那边要的语音发到主人的 QQ 私聊。
|
|
227
|
+
* 条件:是主人、填了主人 QQ、QQ 连着、「说」那组配好了、没设成从不发语音、QQ 私聊没暂停;不行返回 ''。
|
|
228
|
+
*/
|
|
229
|
+
voiceRelayTarget(channelId) {
|
|
230
|
+
const [kind, id] = splitChannelId(channelId);
|
|
231
|
+
const isMaster = id !== '' && (kind === 'douyin' ? id === this.douyinConfig().masterName : kind === 'bili' ? id === this.biliConfig().masterUid : false);
|
|
232
|
+
const qq = this.masterQq();
|
|
233
|
+
if (!isMaster || qq === '' || this.voice === null || this.voice === undefined || !this.voice.readyFor('tts')) return '';
|
|
234
|
+
if (this.voice.getConfig().speak === 'never' || !this.qqConfig().enabled || !this.channel?.connected) return '';
|
|
235
|
+
const target = `private:${qq}`;
|
|
236
|
+
return this.isPaused(target) ? '' : target;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/** 写了 [语音] 却发成了文字:为什么(记进「动态」)。 */
|
|
240
|
+
voiceFailReason(text) {
|
|
241
|
+
const v = this.voice;
|
|
242
|
+
if (!v) return '没开语音';
|
|
243
|
+
if (!v.readyFor('tts')) return '「说」那组没配好';
|
|
244
|
+
const c = v.getConfig();
|
|
245
|
+
if ([...String(text ?? '')].length > c.maxChars) return `超过 ${c.maxChars} 字`;
|
|
246
|
+
return v.lastError || '合成失败';
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 私信回复里 [语音] 开头的段:能转到主人 QQ 的就合成语音发过去(这段不在私信里发),
|
|
251
|
+
* 不能的去掉标记当文字发。返回私信里要发的文字。
|
|
252
|
+
*/
|
|
253
|
+
async relayDmVoice(row, text) {
|
|
254
|
+
const paras = String(text ?? '').split(/\n{2,}/);
|
|
255
|
+
if (!paras.some((p) => VOICE_TAG_RE.test(p))) return text;
|
|
256
|
+
const target = this.voiceRelayTarget(row.channel_id);
|
|
257
|
+
const where = platformName(row.channel_id);
|
|
258
|
+
const keep = [];
|
|
259
|
+
for (const p of paras) {
|
|
260
|
+
if (!VOICE_TAG_RE.test(p)) { keep.push(p); continue; }
|
|
261
|
+
const said = p.replace(VOICE_TAG_RE, '').trim();
|
|
262
|
+
if (said === '') continue;
|
|
263
|
+
if (target === '') {
|
|
264
|
+
this.note('filtered', { channelId: row.channel_id, title: row.title, text: said, reason: `${where}私信发不了语音,改成打字` });
|
|
265
|
+
keep.push(said);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const bytes = await this.voice.synthesize(said);
|
|
269
|
+
if (!bytes) {
|
|
270
|
+
this.note('filtered', { channelId: row.channel_id, title: row.title, text: said, reason: `语音没发成(${this.voiceFailReason(said)}),改成打字` });
|
|
271
|
+
keep.push(said);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const id = await this.channel.send(target, [{ type: 'record', data: { file: `base64://${bytes.toString('base64')}` } }]);
|
|
276
|
+
this.rememberOwn?.(id, `[语音] ${said}`);
|
|
277
|
+
this.note('sent', { channelId: target, title: 'QQ·主人', text: `🔊${said}`, reason: `${where}里要的语音,发到 QQ 私聊` });
|
|
278
|
+
this.soul.journalOut?.(target, `[语音] ${said}`);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
this.log('error', `[fish-soul] 语音发到 QQ 失败:${String(error instanceof Error ? error.message : error)}`);
|
|
281
|
+
this.note('filtered', { channelId: row.channel_id, title: row.title, text: said, reason: '语音发到 QQ 失败,改成打字' });
|
|
282
|
+
keep.push(said);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return keep.join('\n\n');
|
|
286
|
+
},
|
|
287
|
+
|
|
223
288
|
async sendBili(row, text, images = []) {
|
|
224
289
|
if (this.bili === null) return false;
|
|
225
290
|
const mid = row.channel_id.slice('bili:'.length);
|
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); }
|
|
@@ -974,7 +990,9 @@ export class ChatGateway {
|
|
|
974
990
|
const platform = platformOf(row.channel_id);
|
|
975
991
|
if (platform === 'bili' || platform === 'douyin') {
|
|
976
992
|
const images = await this.dmImages(row, 图, st);
|
|
977
|
-
|
|
993
|
+
// [语音] 段:主人的话转成语音发到 QQ 私聊,其余去掉标记当文字发
|
|
994
|
+
const dmText = (await this.relayDmVoice(row, 净文字)).split(/\n{2,}/).map((p) => p.replace(VOICE_RE, '')).filter((p) => p.trim() !== '').join('\n\n');
|
|
995
|
+
if (dmText === '' && images.length === 0) return true;
|
|
978
996
|
const ok = platform === 'bili' ? await this.sendBili(row, dmText, images) : await this.sendDouyin(row, dmText, images);
|
|
979
997
|
this.note(ok ? 'sent' : 'failed', { channelId: row.channel_id, title: row.title, text: dmText || '[图片]', reason: ok ? '' : '发送失败', memories });
|
|
980
998
|
if (ok && dmText) this.soul.journalOut?.(row.channel_id, dmText);
|
|
@@ -1003,8 +1021,12 @@ export class ChatGateway {
|
|
|
1003
1021
|
for (const [i, p] of paragraphs.entries()) {
|
|
1004
1022
|
if (!VOICE_RE.test(p)) continue;
|
|
1005
1023
|
paragraphs[i] = p.replace(VOICE_RE, '').trim();
|
|
1006
|
-
const
|
|
1024
|
+
const moodOk = this.moodVoice(profile);
|
|
1025
|
+
const bytes = paragraphs[i] !== '' && moodOk ? await this.voice?.synthesize(paragraphs[i]) ?? null : null;
|
|
1007
1026
|
if (bytes) voiced[i] = { type: 'record', data: { file: `base64://${bytes.toString('base64')}` } };
|
|
1027
|
+
else if (paragraphs[i] !== '') {
|
|
1028
|
+
this.note('filtered', { channelId: row.channel_id, title: row.title, text: paragraphs[i], reason: `语音没发成(${moodOk ? this.voiceFailReason(paragraphs[i]) : `${profile.mood || profile.tone || '心情'},不想说话`}),改成打字` });
|
|
1029
|
+
}
|
|
1008
1030
|
}
|
|
1009
1031
|
const messages = paragraphs.map((p, i) => (voiced[i] ? [voiced[i]] : isGroup ? 拆成消息段(p) : [{ type: 'text', data: { text: p } }]));
|
|
1010
1032
|
if (images.length > 0) {
|
|
@@ -1014,15 +1036,33 @@ export class ChatGateway {
|
|
|
1014
1036
|
// 语音消息不能带引用
|
|
1015
1037
|
if (replyTo && messages[0][0]?.type !== 'record') messages[0].unshift({ type: 'reply', data: { id: String(replyTo) } });
|
|
1016
1038
|
let allOk = true;
|
|
1039
|
+
// 这个聊天里还有没重发完的:新回复排到后面,顺序不乱
|
|
1040
|
+
let queued = this.outbox.hasQueued(row.channel_id);
|
|
1041
|
+
let queuedCount = 0;
|
|
1017
1042
|
await this.sleep(this.firstDelay(profile));
|
|
1018
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
|
+
}
|
|
1019
1050
|
if (i > 0) await this.sleep(Math.min(6000, typingDelayMs(paragraphs[i] ?? '', this.random) * profile.delay));
|
|
1020
1051
|
try {
|
|
1021
1052
|
const id = await this.channel.send(row.channel_id, segs);
|
|
1022
|
-
this.rememberOwn(id,
|
|
1053
|
+
this.rememberOwn(id, text);
|
|
1023
1054
|
} catch (error) {
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
+
}
|
|
1026
1066
|
}
|
|
1027
1067
|
}
|
|
1028
1068
|
if (isGroup) {
|
|
@@ -1037,7 +1077,8 @@ export class ChatGateway {
|
|
|
1037
1077
|
this.soul.journalOut?.(row.channel_id, paragraphs.join('\n'));
|
|
1038
1078
|
this.slang?.observe(row.channel_id, '', paragraphs.join(' '), { self: true }); // 学说法时当上下文,不从她自己的话里学
|
|
1039
1079
|
}
|
|
1040
|
-
|
|
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 });
|
|
1041
1082
|
return allOk;
|
|
1042
1083
|
}
|
|
1043
1084
|
}
|