dsh-fish-soul 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/client.models.js +10 -1
- package/lib/config-schema.js +3 -1
- package/lib/douyin-social.js +109 -6
- package/lib/gateway.js +26 -3
- package/lib/index.js +8 -1
- package/lib/routes-v07.js +10 -2
- package/lib/social.js +35 -6
- package/package.json +1 -1
package/README.md
CHANGED
package/lib/client.models.js
CHANGED
|
@@ -92,7 +92,7 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
|
|
93
93
|
/** 上网查:DSH 自带的 web_search,或自己填 key 的搜索服务(博查 / Tavily / Brave / SearXNG) */
|
|
94
94
|
function Web({ data, api, notify, load, ui }) {
|
|
95
|
-
const { h, Group, Row, Switch, Num, Muted, State, Select, Secret, Input, Button } = ui;
|
|
95
|
+
const { h, Group, Row, Switch, Num, Muted, State, Select, Secret, Input, Button, Seg } = ui;
|
|
96
96
|
const w = data.web ?? {};
|
|
97
97
|
const providers = w.providers ?? [{ id: "host", label: "DSH 自带" }];
|
|
98
98
|
const cur = providers.find((p) => p.id === (w.provider ?? "host")) ?? providers[0];
|
|
@@ -105,6 +105,11 @@ window.__ModuleLoader__.load({
|
|
|
105
105
|
const r = await api("POST", "/models/config", { web: { enabled: w.enabled, perDay: Number(perDay), ...p } });
|
|
106
106
|
if (r?.ok) { if (p.apiKey !== undefined) setKey(""); load(); } else notify(r?.error ?? "保存失败");
|
|
107
107
|
}
|
|
108
|
+
async function saveSessionAllow(v) { // #37 会话工具放行档位
|
|
109
|
+
const r = await api("POST", "/models/config", { sessionAllow: v });
|
|
110
|
+
if (!r?.ok) notify(r?.error ?? "保存失败");
|
|
111
|
+
else load();
|
|
112
|
+
}
|
|
108
113
|
async function test() {
|
|
109
114
|
setBusy(true); setResult(null);
|
|
110
115
|
const r = await api("POST", "/web/test", { query: "今天有什么新闻" });
|
|
@@ -118,6 +123,10 @@ window.__ModuleLoader__.load({
|
|
|
118
123
|
Row({ title: "聊天时可以上网查", desc: w.available ? undefined : why },
|
|
119
124
|
w.enabled && w.available ? State(w.usedToday >= w.perDay ? "warning" : "done", `今天 ${w.usedToday}/${w.perDay}`) : null,
|
|
120
125
|
h(Switch, { label: "聊天时可以上网查", checked: w.enabled === true, disabled: !w.available && !w.enabled, onChange: (v) => save({ enabled: v }) })),
|
|
126
|
+
Row({ title: "聊天会话能用哪些工具", desc: "只放记忆和上网最安全;「只对主人放开」是主人的私聊会话放开宿主全部工具(其他人不变),「全放开」是所有聊天会话都放。放开后已开着的会话要在 DSH 里重开一次才生效。run_code 始终不放行" },
|
|
127
|
+
Seg({ label: "聊天会话能用哪些工具", value: w.sessionAllow ?? "memory", onChange: (v) => saveSessionAllow(v), options: [
|
|
128
|
+
{ value: "memory", label: "只记忆+上网" }, { value: "master", label: "只对主人放开" }, { value: "all", label: "全放开" }
|
|
129
|
+
] })),
|
|
121
130
|
Row({ title: "搜索服务", desc: cur.id === "host" && w.hostState === "unknown" ? `${cur.hint}。插件这边检测不到不代表没有(DSH 的联网工具常按会话挂载),打开后问她一件最近的事试试;查成过一次这里会显示「用过」` : cur.hint },
|
|
122
131
|
Select({ value: cur.id, "aria-label": "搜索服务", onChange: (e) => { setResult(null); save({ provider: e.target.value }); } },
|
|
123
132
|
...providers.map((p) => h("option", { key: p.id, value: p.id }, p.id === "host" && w.hostState === "unknown" ? `${p.label}(没检测到)` : p.id === "host" && w.hostState === "seen" ? `${p.label}(用过)` : p.label)))),
|
package/lib/config-schema.js
CHANGED
|
@@ -73,7 +73,9 @@ export function fishSoulConfigSchema(z) {
|
|
|
73
73
|
provider: z.string().default('host'), // host | bocha | tavily | brave | searxng
|
|
74
74
|
apiKeys: z.dict(z.string()).default({}), // 每个搜索服务各存一个 key,切换不丢
|
|
75
75
|
baseURL: z.string().default('') // SearXNG 地址
|
|
76
|
-
}).default({})
|
|
76
|
+
}).default({}),
|
|
77
|
+
// #37 聊天会话(QQ / B站 / 抖音)放行哪些工具:memory 只放记忆 + 联网(默认)| master 主人的私聊会话放开宿主全部工具 | all 所有会话全放
|
|
78
|
+
sessionAllow: z.string().default('memory')
|
|
77
79
|
}).default({}),
|
|
78
80
|
onboarding: z.object({ dismissed: z.boolean().default(false) }).default({}),
|
|
79
81
|
memory: z.object({ // v0.8 聊完自动记:一段聊天空下来 idleMinutes 分钟(或攒够 maxLines 句)就整理一次
|
package/lib/douyin-social.js
CHANGED
|
@@ -39,6 +39,25 @@ export function noteTitle(text) {
|
|
|
39
39
|
return first.slice(0, 20);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** 心情 → cardJs themes 的下标(顺序:米黄暖 / 浅蓝丧 / 淡紫梦 / 浅绿清 / 粉甜)。 */
|
|
43
|
+
export const MOOD_THEME = { warm: 0, blue: 1, dream: 2, fresh: 3, pink: 4 };
|
|
44
|
+
const MOOD_LIST = 'warm 暖/治愈、blue 丧/低落、dream 梦幻/恍惚、fresh 清新/日常、pink 甜/开心';
|
|
45
|
+
/** 抖音 2026 图文号高频 BGM 池(都是音乐库搜得到的热歌)。 */
|
|
46
|
+
const MUSIC_POOL = ['月亮照山川', '晚风心里吹', '若月亮没来', '会开花的云', '下一个天亮', '落在生命里的光', '一路生花', '离别开出花', '茶花开了', '无名的人', '远山少年', '小美满', '人间烟火', '小村庄月弯弯', '亲爱的你啊'];
|
|
47
|
+
|
|
48
|
+
/** 让模型给文案配心情 + BGM,返回 {theme, music};任何一步出问题都安全兜底,不耽误发布。 */
|
|
49
|
+
export function pickMusicTheme(raw, random = Math.random) {
|
|
50
|
+
const fallback = { theme: Math.floor(random() * 5), music: '' };
|
|
51
|
+
const m = String(raw ?? '').match(/\{[^{}]*\}/s);
|
|
52
|
+
if (!m) return fallback;
|
|
53
|
+
let j;
|
|
54
|
+
try { j = JSON.parse(m[0]); } catch { return fallback; }
|
|
55
|
+
const mood = String(j?.mood ?? '').toLowerCase().trim();
|
|
56
|
+
const theme = Object.hasOwn(MOOD_THEME, mood) ? MOOD_THEME[mood] : fallback.theme;
|
|
57
|
+
const music = MUSIC_POOL.find((s) => s === String(j?.music ?? '').trim()) ?? '';
|
|
58
|
+
return { theme, music, reason: String(j?.reason ?? '').trim().slice(0, 40) };
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
/** 评论去重用的 id:作品 + 谁 + 说了什么。 */
|
|
43
62
|
export function commentKey(workId, author, content) {
|
|
44
63
|
return `${workId}:${createHash('sha1').update(`${author}\n${content}`).digest('hex').slice(0, 16)}`;
|
|
@@ -131,6 +150,81 @@ async function waitFor(fn, { tries = 10, gapMs = 1000 } = {}) {
|
|
|
131
150
|
return null;
|
|
132
151
|
}
|
|
133
152
|
|
|
153
|
+
/** 图文编辑页里配 BGM:点「选择音乐」→ 搜歌名 → 用第一首。整个尽力而为,失败返回 false,绝不拦发布。 */
|
|
154
|
+
async function pickNoteMusic(br, music) {
|
|
155
|
+
if (!music) return false;
|
|
156
|
+
try {
|
|
157
|
+
const sel = await br.eval(String.raw`(() => {
|
|
158
|
+
// 页面有多个「选择音乐」文本节点(左边灰化标签区、右边真正可点的入口)。取最后一个——真正可点的那颗。
|
|
159
|
+
const els = [...document.querySelectorAll('div, span, button')].filter((x) => x.children.length === 0 && /选择音乐/.test((x.innerText || '').trim()));
|
|
160
|
+
const el = els[els.length - 1];
|
|
161
|
+
if (!el) return null;
|
|
162
|
+
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
163
|
+
const r = el.getBoundingClientRect();
|
|
164
|
+
return r.width && r.height ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null;
|
|
165
|
+
})()`).catch(() => null);
|
|
166
|
+
if (!sel) return false;
|
|
167
|
+
await realClick(br, sel.x, sel.y);
|
|
168
|
+
await sleep(1200);
|
|
169
|
+
const opened = await br.eval('Boolean(document.querySelector(\'input[placeholder*="搜索"]\'))').catch(() => false);
|
|
170
|
+
if (!opened) return false;
|
|
171
|
+
const box = await waitFor(() => br.eval('Boolean(document.querySelector(\'input[placeholder*="搜索"]\'))'), { tries: 6, gapMs: 800 });
|
|
172
|
+
if (!box) return await closeMusicPanel(br, false);
|
|
173
|
+
const p = await br.eval(`(() => { const el = document.querySelector('input[placeholder*="搜索"]'); if (!el) return null; el.scrollIntoView({ block: 'center' }); el.focus(); const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`).catch(() => null);
|
|
174
|
+
if (p) await realClick(br, p.x, p.y);
|
|
175
|
+
await sleep(500);
|
|
176
|
+
await br.send('Input.insertText', { text: music }, br.sessionId).catch(() => {});
|
|
177
|
+
await sleep(800);
|
|
178
|
+
await br.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, br.sessionId).catch(() => {});
|
|
179
|
+
await br.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, br.sessionId).catch(() => {});
|
|
180
|
+
// 结果列表里要「悬停」才出「使用」按钮(实测:直接点条目只会试听,不选中)。
|
|
181
|
+
// 所以先悬停第一条结果,等「使用」冒出来再点;实在没有「使用」就点条目本身兜底。
|
|
182
|
+
const used = await waitFor(async () => {
|
|
183
|
+
const row = await br.eval(`(() => {
|
|
184
|
+
const want = ${JSON.stringify(music)};
|
|
185
|
+
const all = [...document.querySelectorAll('div')].filter((x) => (x.innerText || '').includes(want) && x.getBoundingClientRect().width > 0 && x.getBoundingClientRect().height > 50);
|
|
186
|
+
const el = all.sort((a, b) => a.getBoundingClientRect().height - b.getBoundingClientRect().height)[0];
|
|
187
|
+
if (!el) return null;
|
|
188
|
+
const r = el.getBoundingClientRect();
|
|
189
|
+
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
|
190
|
+
})()`).catch(() => null);
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
await br.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: row.x, y: row.y }, br.sessionId).catch(() => {});
|
|
193
|
+
await sleep(700);
|
|
194
|
+
const btn = await br.eval(`(() => {
|
|
195
|
+
const b = [...document.querySelectorAll('div, span, button')].filter((x) => x.children.length === 0 && /^(使用(歌曲)?|配乐|选它)$/.test((x.innerText || '').trim()) && x.getBoundingClientRect().width > 0)[0];
|
|
196
|
+
if (!b) return null;
|
|
197
|
+
const r = b.getBoundingClientRect();
|
|
198
|
+
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
|
199
|
+
})()`).catch(() => null);
|
|
200
|
+
return btn ?? row;
|
|
201
|
+
}, { tries: 8, gapMs: 800 });
|
|
202
|
+
if (used) await realClick(br, used.x, used.y);
|
|
203
|
+
await sleep(2500);
|
|
204
|
+
// 面板自己会收起;没收就看扩展信息那儿是不是真出现了歌名
|
|
205
|
+
const done = await waitFor(() => br.eval(`(() => {
|
|
206
|
+
if (!document.querySelector('input[placeholder*="搜索"]')) return true;
|
|
207
|
+
const t = document.body.innerText || '';
|
|
208
|
+
const i = t.indexOf('扩展信息');
|
|
209
|
+
return i >= 0 && t.slice(i, i + 200).includes(${JSON.stringify(music)}) ? true : null;
|
|
210
|
+
})()`).catch(() => null), { tries: 6, gapMs: 800 });
|
|
211
|
+
return await closeMusicPanel(br, done === true);
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 面板能关就关(点「完成」或关闭按钮,实在没有就算了),返回结果。 */
|
|
218
|
+
async function closeMusicPanel(br, result) {
|
|
219
|
+
await br.eval(String.raw`(() => {
|
|
220
|
+
const el = [...document.querySelectorAll('div, span, button')].find((x) => x.children.length === 0 && /^(完成|收起|关闭|取消)$/.test((x.innerText || '').trim()));
|
|
221
|
+
if (el) el.click();
|
|
222
|
+
return true;
|
|
223
|
+
})()`).catch(() => false);
|
|
224
|
+
await sleep(600);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
134
228
|
/** 真正的页面驱动(测试里换成假的)。全部都得在 host.exclusive 里调。 */
|
|
135
229
|
export function douyinSocialDriver(host) {
|
|
136
230
|
const b = () => {
|
|
@@ -145,8 +239,8 @@ export function douyinSocialDriver(host) {
|
|
|
145
239
|
return typeof out === 'string' && out !== '' ? Buffer.from(out, 'base64') : null;
|
|
146
240
|
},
|
|
147
241
|
|
|
148
|
-
/** 创作者中心发一条图文。返回 {ok, code?, error?}。 */
|
|
149
|
-
async publishNote({ title, text, file }) {
|
|
242
|
+
/** 创作者中心发一条图文。返回 {ok, code?, error?, musicPicked?}。music 非空时发布前尽量配 BGM,失败不影响发布。 */
|
|
243
|
+
async publishNote({ title, text, file, music }) {
|
|
150
244
|
const br = b();
|
|
151
245
|
await br.goto(CREATOR_UPLOAD_URL, { settleMs: 8000 });
|
|
152
246
|
const url = String(await br.eval('location.href').catch(() => '') ?? '');
|
|
@@ -183,6 +277,7 @@ export function douyinSocialDriver(host) {
|
|
|
183
277
|
await sleep(1000);
|
|
184
278
|
// 等图片传完("上传中"没了)
|
|
185
279
|
await waitFor(async () => !/上传中|正在上传/.test(String(await br.eval('document.body.innerText').catch(() => '') ?? '')), { tries: 30 });
|
|
280
|
+
const musicPicked = await pickNoteMusic(br, String(music ?? ''));
|
|
186
281
|
const clicked = await br.eval(String.raw`(() => {
|
|
187
282
|
const btns = [...document.querySelectorAll('button')].filter((x) => (x.innerText || '').trim() === '发布' && !x.disabled);
|
|
188
283
|
const el = btns[btns.length - 1];
|
|
@@ -200,7 +295,7 @@ export function douyinSocialDriver(host) {
|
|
|
200
295
|
}, { tries: 25 });
|
|
201
296
|
if (done?.verify) return { ok: false, code: 'NEEDS_VERIFY', error: '抖音要验证(短信 / 滑块)才能发' };
|
|
202
297
|
if (!done?.ok) return { ok: false, code: 'NOT_CONFIRMED', error: '点了发布,但没等到「发布成功」(可能在审核,或者页面改版了)' };
|
|
203
|
-
return { ok: true };
|
|
298
|
+
return { ok: true, musicPicked: musicPicked === true };
|
|
204
299
|
},
|
|
205
300
|
|
|
206
301
|
/** 「我的」作品(最多 limit 个,新的在前)。 */
|
|
@@ -327,7 +422,7 @@ export class DouyinSocial extends SocialBase {
|
|
|
327
422
|
}
|
|
328
423
|
|
|
329
424
|
postStyle() {
|
|
330
|
-
return '
|
|
425
|
+
return '你写的话会印在一张纯色文字卡片上,同时当作品描述。像真人随手发的那种:短、碎、口语,一般 10~60 字,可以分两三行。偶尔(不是每条)可以带个轻量的钩子:一个小问句、一句替大家说出心声的吐槽,像跟朋友聊天,别硬凑,也别每条都来。不要"记录生活"这种四字口号,不要文艺腔。';
|
|
331
426
|
}
|
|
332
427
|
|
|
333
428
|
whoIs(it, master) {
|
|
@@ -343,15 +438,23 @@ export class DouyinSocial extends SocialBase {
|
|
|
343
438
|
|
|
344
439
|
async publish(text) {
|
|
345
440
|
const parts = this.clock.getRoleDateParts();
|
|
346
|
-
|
|
441
|
+
// 配心情 + BGM:LLM 挑不出来就退回随机主题、不配音乐,不影响发布
|
|
442
|
+
let pick = { theme: Math.floor(this.random() * 5), music: '' };
|
|
443
|
+
try {
|
|
444
|
+
const raw = String((await this.callLlm(`给这条抖音图文配「心情」和「背景音乐」。\n\n图文正文:\n${text}\n\n心情只能是这 5 个之一:${MOOD_LIST}。\n背景音乐从这个池子里挑一首(歌名原样照抄),文案不适合配歌就选空串:${MUSIC_POOL.join(' / ')}。\n只输出一行 JSON:{"mood":"warm|blue|dream|fresh|pink","music":"歌名或空串","reason":"一句话为什么"}`)) ?? '').trim();
|
|
445
|
+
pick = pickMusicTheme(raw, this.random);
|
|
446
|
+
if (pick.music) this.note('post', { title: this.label, reason: `配了首《${pick.music}》${pick.reason ? `(${pick.reason})` : ''}` });
|
|
447
|
+
} catch { /* 挑不出来就算了 */ }
|
|
448
|
+
const png = await this.host.exclusive('画卡片', () => this.driver.renderCard(text, parts.dateStr.replace(/-/g, '.'), pick.theme), { maxMs: 30_000 });
|
|
347
449
|
if (!png) throw new Error('卡片没画出来');
|
|
348
450
|
mkdirSync(this.cardDir, { recursive: true });
|
|
349
451
|
const file = join(this.cardDir, `card-${Date.now()}.png`);
|
|
350
452
|
writeFileSync(file, png);
|
|
351
|
-
const r = await this.host.exclusive('发抖音图文', () => this.driver.publishNote({ title: noteTitle(text), text, file }), { maxMs: 4 * 60_000 });
|
|
453
|
+
const r = await this.host.exclusive('发抖音图文', () => this.driver.publishNote({ title: noteTitle(text), text, file, music: pick.music }), { maxMs: 4 * 60_000 });
|
|
352
454
|
if (r?.code === 'NEEDS_VERIFY') this.blocked('发作品');
|
|
353
455
|
if (r?.code === 'NOT_LOGGED_IN') this.notify('douyin.creator', '想在抖音发图文,但创作者中心没登录:在抖音浏览器里打开 creator.douyin.com 登录一次就好');
|
|
354
456
|
if (!r?.ok) throw new Error(r?.error ?? '没发出去');
|
|
457
|
+
if (pick.music && r?.musicPicked === false) this.note('post', { title: this.label, reason: `《${pick.music}》没配上(页面没找到或改版了),图发出去了但没 BGM` });
|
|
355
458
|
this.notify('douyin.creator', null);
|
|
356
459
|
return '';
|
|
357
460
|
}
|
package/lib/gateway.js
CHANGED
|
@@ -359,6 +359,7 @@ export class ChatGateway {
|
|
|
359
359
|
/**
|
|
360
360
|
* 会话里放行哪些工具。插件自己的工具按全局表筛;DSH 的联网工具(web_search / web_fetch)是按会话挂的,
|
|
361
361
|
* 全局表里经常查不到(#26),不筛——真没有时模型调不到而已。agentCtx 有自己的工具表时也认它。
|
|
362
|
+
* #37:tools.sessionAllow = 'master' / 'all' 时放开宿主全部工具(restrict 都不调,让会话拿到默认组合)。
|
|
362
363
|
*/
|
|
363
364
|
allowedTools(agentCtx) {
|
|
364
365
|
const has = (reg, n) => { try { return typeof reg?.get === 'function' && reg.get(n) !== undefined; } catch { return false; } };
|
|
@@ -367,14 +368,36 @@ export class ChatGateway {
|
|
|
367
368
|
WEB_TOOLS.includes(n) || typeof tools?.get !== 'function' || has(tools, n) || has(agentCtx?.tools, n));
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
/** #37 聊天会话的工具放行档位:memory(默认,只放记忆 + 联网)| master(主人私聊全放开)| all(所有会话全放开)。 */
|
|
372
|
+
sessionAllowMode() {
|
|
373
|
+
const v = String(this.getConfig().tools?.sessionAllow ?? 'memory').trim().toLowerCase();
|
|
374
|
+
return v === 'master' || v === 'all' ? v : 'memory';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** #37 这个 channel 是不是主人的私聊会话(QQ 主人号 / B站 masterUid / 抖音 masterName)。 */
|
|
378
|
+
isMasterChannel(channelId) {
|
|
379
|
+
const [kind, id] = splitChannelId(channelId);
|
|
380
|
+
if (kind === 'group' || kind === 'douyin-group') return false;
|
|
381
|
+
if (kind === 'bili') return id === String(this.biliConfig().masterUid ?? '');
|
|
382
|
+
if (kind === 'douyin') return id === String(this.douyinConfig().masterName ?? '');
|
|
383
|
+
return id === this.masterQq();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** #37 当前放行档位下,这个聊天建/恢复的会话要不要放开宿主全部工具。 */
|
|
387
|
+
sessionOpenFor(channelId) {
|
|
388
|
+
const mode = this.sessionAllowMode();
|
|
389
|
+
return mode === 'all' || (mode === 'master' && this.isMasterChannel(channelId));
|
|
390
|
+
}
|
|
391
|
+
|
|
370
392
|
/** 新建/恢复会话时的 setup:挂默认预设,然后把全局工具收窄到记忆工具(失败就整个建会话失败,宁可不回也不放开 shell)。 */
|
|
371
|
-
async setupAgent(agentCtx) {
|
|
393
|
+
async setupAgent(agentCtx, channelId) {
|
|
372
394
|
const presets = service(this.ctx, 'agentPresets');
|
|
373
395
|
if (presets && typeof presets.mount === 'function') {
|
|
374
396
|
try { await presets.mount(agentCtx); } catch (error) {
|
|
375
397
|
this.log('warn', `[fish-soul] 挂默认预设失败(按无预设继续):${String(error instanceof Error ? error.message : error)}`);
|
|
376
398
|
}
|
|
377
399
|
}
|
|
400
|
+
if (channelId !== undefined && this.sessionOpenFor(channelId)) return; // #37 主人/全部放开:不 restrict,会话用宿主默认工具组合
|
|
378
401
|
const allow = this.allowedTools(agentCtx);
|
|
379
402
|
if (typeof agentCtx?.tools?.restrict === 'function' && allow.length > 0) {
|
|
380
403
|
try {
|
|
@@ -431,7 +454,7 @@ export class ChatGateway {
|
|
|
431
454
|
const handle = await agents.resume({
|
|
432
455
|
resumeSessionId: row.session_id,
|
|
433
456
|
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
434
|
-
setup: (agentCtx) => this.setupAgent(agentCtx)
|
|
457
|
+
setup: (agentCtx) => this.setupAgent(agentCtx, key)
|
|
435
458
|
});
|
|
436
459
|
this.log('info', `[fish-soul] 已恢复会话 ${row.title || key}`);
|
|
437
460
|
return handle.agent;
|
|
@@ -482,7 +505,7 @@ export class ChatGateway {
|
|
|
482
505
|
sessionId,
|
|
483
506
|
meta: { cwd: ws.path ?? workspacePath, ...(presetId !== undefined ? { agentPreset: presetId } : {}) },
|
|
484
507
|
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
485
|
-
setup: (agentCtx) => this.setupAgent(agentCtx)
|
|
508
|
+
setup: (agentCtx) => this.setupAgent(agentCtx, chat.channelId)
|
|
486
509
|
});
|
|
487
510
|
const title = await this.chatTitle(chat);
|
|
488
511
|
try { await ws.attachSession?.(sessionId); } catch (error) {
|
package/lib/index.js
CHANGED
|
@@ -861,9 +861,14 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
861
861
|
|
|
862
862
|
// 兜底护栏:就算用户在界面里重新打开 QQ 会话(宿主按默认组合恢复、没走我们的 restrict),
|
|
863
863
|
// 这些会话里也只能调记忆工具。不放行 run_code:PTC 模式下它能跑任意程序,代价是 PTC 模式里记忆工具不可用。
|
|
864
|
+
// #37:tools.sessionAllow = 'master' / 'all' 时,放开的会话不再拦宿主工具(run_code 仍然拦)。
|
|
864
865
|
try {
|
|
865
866
|
if (typeof ctx.tools.guard === 'function') {
|
|
866
867
|
const allowed = new Set(QQ_SESSION_TOOLS);
|
|
868
|
+
const sessionOpen = (id) => {
|
|
869
|
+
const row = gateway?.chatForSession(id);
|
|
870
|
+
return row !== null && row !== undefined && gateway.sessionOpenFor(row.channel_id) === true;
|
|
871
|
+
};
|
|
867
872
|
ctx.effect(() => ctx.tools.guard((exec) => {
|
|
868
873
|
const id = exec?.agent?.id;
|
|
869
874
|
const ours = id !== undefined && id !== null && gateway?.isOurSession(id) === true;
|
|
@@ -879,7 +884,9 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
879
884
|
countWebUse(id, exec.name);
|
|
880
885
|
return undefined;
|
|
881
886
|
}
|
|
882
|
-
|
|
887
|
+
if (exec.name === 'run_code') return `聊天会话里不能运行代码(放开工具也不放 run_code:它能跑任意程序)`;
|
|
888
|
+
if (sessionOpen(id)) return undefined; // #37 主人/全部放开:宿主工具都放
|
|
889
|
+
return allowed.has(exec.name) ? undefined : `聊天会话(QQ / B站 / 抖音)里不能用 ${exec.name}(只开放记忆工具和上网查;设置页「模型」里可以放开)`;
|
|
883
890
|
}), 'dsh-fish-soul: qq tool guard');
|
|
884
891
|
} else {
|
|
885
892
|
log('warn', '[fish-soul] 宿主没有 tools.guard:QQ 会话的工具限制只靠建会话时的 restrict');
|
package/lib/routes-v07.js
CHANGED
|
@@ -86,7 +86,8 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
86
86
|
return {
|
|
87
87
|
enabled: w.enabled, perDay: w.perDay, usedToday: webUsedToday?.() ?? 0, available: hasWebTools?.() === true,
|
|
88
88
|
provider: w.provider, providers: WEB_PROVIDERS, hostAvailable: hostWebAvailable?.() === true, hostState: hostWebState?.() ?? 'unknown',
|
|
89
|
-
hasApiKey: w.apiKey !== '', apiKeyMasked: maskKey(w.apiKey), baseURL: w.baseURL
|
|
89
|
+
hasApiKey: w.apiKey !== '', apiKeyMasked: maskKey(w.apiKey), baseURL: w.baseURL,
|
|
90
|
+
sessionAllow: gateway?.sessionAllowMode?.() ?? 'memory' // #37 聊天会话工具放行档位
|
|
90
91
|
};
|
|
91
92
|
};
|
|
92
93
|
|
|
@@ -108,7 +109,7 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
108
109
|
});
|
|
109
110
|
return true;
|
|
110
111
|
}
|
|
111
|
-
// ── 契约 22:保存按功能选的模型 / 价格 / 上网查
|
|
112
|
+
// ── 契约 22:保存按功能选的模型 / 价格 / 上网查 / 会话工具放行档位(#37)──
|
|
112
113
|
if (path === '/models/config' && post) {
|
|
113
114
|
if (!guard()) return true;
|
|
114
115
|
const b = await body();
|
|
@@ -125,6 +126,13 @@ export async function handleV07Route({ method, path, url, req, res, send, readBo
|
|
|
125
126
|
if (typeof b.web.baseURL === 'string') web.baseURL = str(b.web.baseURL, 300).replace(/\/+$/, '');
|
|
126
127
|
patch.tools = { web };
|
|
127
128
|
}
|
|
129
|
+
// #37:聊天会话放行哪些工具(memory / master / all),web 那套要跟着一起带上,别把已有的覆盖丢了
|
|
130
|
+
if (b.sessionAllow !== undefined) {
|
|
131
|
+
const v = String(b.sessionAllow);
|
|
132
|
+
const tools = patch.tools ?? {};
|
|
133
|
+
if (v === 'memory' || v === 'master' || v === 'all') tools.sessionAllow = v;
|
|
134
|
+
patch.tools = tools;
|
|
135
|
+
}
|
|
128
136
|
saveConfig(patch);
|
|
129
137
|
send(200, { ok: true, web: webState() });
|
|
130
138
|
return true;
|
package/lib/social.js
CHANGED
|
@@ -121,9 +121,11 @@ export class SocialBase {
|
|
|
121
121
|
if (st.count >= c.perDay || st.tries >= c.perDay + 2) return false;
|
|
122
122
|
if (st.lastVirtual > 0 && this.clock.now() - st.lastVirtual < POST_MIN_GAP_VIRTUAL_MS) return false;
|
|
123
123
|
if (Date.now() - this.lastPostRealMs < this.postRealGapMs) return false;
|
|
124
|
-
//
|
|
124
|
+
// 流量高峰加权:12/18/21 点档 ×2,普通时段 ×1,高峰期发文概率翻倍
|
|
125
|
+
const peak = [12, 13, 18, 19, 20, 21, 22].includes(parts.hour) ? 2 : 1;
|
|
126
|
+
// 把今天剩下的条数摊到剩下的 tick 上(15 分钟一次),加权后更容易在高峰发出去
|
|
125
127
|
const ticksLeft = Math.max(1, (end - parts.hour) * 4 - Math.floor((parts.minute ?? 0) / 15));
|
|
126
|
-
if (this.random() >= (c.perDay - st.count) / ticksLeft) return false;
|
|
128
|
+
if (this.random() >= (c.perDay - st.count) * peak / ticksLeft) return false;
|
|
127
129
|
void this.post({ parts, slot, mood }).catch((error) => this.log('warn', `[fish-soul] ${this.label}发动态失败:${errText(error)}`));
|
|
128
130
|
return true;
|
|
129
131
|
}
|
|
@@ -135,23 +137,50 @@ export class SocialBase {
|
|
|
135
137
|
|
|
136
138
|
/** 平台特有的写法要求(抖音图文会把正文印成卡片)。 */
|
|
137
139
|
postStyle() {
|
|
138
|
-
return '
|
|
140
|
+
return '像真人发的那种动态,口语、有网感,像跟朋友发消息,不要端着。';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 每次发文随机挑一个「具体行为指令」,比「随手发一句」发散得多。 */
|
|
144
|
+
postTopic() {
|
|
145
|
+
const topics = [
|
|
146
|
+
'用你自己的风格冷吐槽一下刚看到的社会现象或互联网怪象',
|
|
147
|
+
'分享此刻的碎片瞬间:刚刚在干嘛、看到了什么、一个小想法',
|
|
148
|
+
'像记日记一样写一两句今天的小事,不用有主题',
|
|
149
|
+
'毒舌点评一下大家都在讨论的东西,别当真,就图一乐',
|
|
150
|
+
'发一句只有此刻才有的感慨(时间、天气、此刻的心境)',
|
|
151
|
+
'说说今天遇到的一件让你想笑/想吐槽/无语的小事',
|
|
152
|
+
'发一句你此刻的真实想法,不用多,一两行就行',
|
|
153
|
+
'用「嘴替」的视角替大家说句心里话(丧的、累的、想躺的都行)'
|
|
154
|
+
];
|
|
155
|
+
return topics[Math.floor(this.random() * topics.length)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 把绝对时间换成「深夜/上午/下午/晚上」的自然提示,像真人会说的时间感。 */
|
|
159
|
+
timeHint(parts) {
|
|
160
|
+
const h = parts?.hour ?? 12;
|
|
161
|
+
if (h < 6) return '深夜/凌晨';
|
|
162
|
+
if (h < 12) return '上午';
|
|
163
|
+
if (h < 18) return '下午';
|
|
164
|
+
return '晚上';
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
postPrompt({ parts, slot, mood }) {
|
|
142
168
|
const p = this.persona();
|
|
143
169
|
// 只拿她自己经历的事(刷到的视频、自己的动态),不拿别人的私事
|
|
144
170
|
const mine = this.db.prepare("SELECT content FROM memory WHERE owner_id = 'self' AND status = 'active' AND kind = 'event' ORDER BY created_at DESC LIMIT 6").all().map((r) => `- ${oneLine(r.content, 80)}`);
|
|
171
|
+
// 单独挑几条「刷到的视频」当题材供她自己发挥:不照抄文案、不刻意视频本身
|
|
172
|
+
const watched = this.db.prepare("SELECT content FROM memory WHERE owner_id = 'self' AND status = 'active' AND kind = 'event' AND content LIKE '我在%刷到%' ORDER BY created_at DESC LIMIT 3").all().map((r) => `- ${oneLine(r.content, 80)}`);
|
|
145
173
|
const posts = this.recentPosts(5).map((r) => `- ${oneLine(r.content, 80)}`);
|
|
146
174
|
const time = parts ? `${parts.dateStr} ${String(parts.hour).padStart(2, '0')}:${String(parts.minute ?? 0).padStart(2, '0')}` : '';
|
|
147
175
|
return [
|
|
148
176
|
`你是「${p.name}」。${p.brief ?? ''}`.trim(),
|
|
149
|
-
|
|
177
|
+
`现在是${this.timeHint(parts)}(${time})${mood ? `,今天整体心情:${mood}` : ''}${slot?.activity ? `,你正在:${oneLine(slot.activity, 40)}` : ''}。`,
|
|
150
178
|
mine.length > 0 ? `你最近的经历:\n${mine.join('\n')}` : '',
|
|
179
|
+
watched.length > 0 ? `你刚刷到过这些(可以从里面挑一条当题材,别逐字照抄别人的文案,说说你自己的感受就行):\n${watched.join('\n')}` : '',
|
|
151
180
|
posts.length > 0 ? `你最近发过的动态(别重复):\n${posts.join('\n')}` : '',
|
|
152
181
|
'',
|
|
153
|
-
this.postStyle()
|
|
154
|
-
'第一人称、口语,一般 15~80 字,不要话题标签、不要 @ 人、不要链接、不要"大家好"之类的开场,不要提到任何具体的人(包括主人)的私事,不要说自己是 AI
|
|
182
|
+
`这条的方向:${this.postTopic()}。${this.postStyle()}`,
|
|
183
|
+
'第一人称、口语,一般 15~80 字,不要话题标签、不要 @ 人、不要链接、不要"大家好"之类的开场,不要提到任何具体的人(包括主人)的私事,不要说自己是 AI 或模型。说人话,别端着,别每条都用"今天"开头。',
|
|
155
184
|
'现在没什么想发的,就只输出 [不发]。只输出动态正文。'
|
|
156
185
|
].filter((l) => l !== '').join('\n');
|
|
157
186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|