dsh-deepmoyu 2.4.1 → 2.6.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/AGENTS.md +3 -1
- package/CHANGELOG.md +37 -0
- package/README.md +4 -2
- package/client.js +62 -5
- package/docs/DEPLOYMENT.md +4 -4
- package/docs/RELEASE_AUDIT.md +5 -5
- package/docs/TEST_MATRIX.md +4 -2
- package/docs/tool-evidence.json +6 -1
- package/index.js +271 -40
- package/lib/background-tools.mjs +2 -0
- package/lib/model-relay.mjs +28 -3
- package/lib/persona.mjs +1 -0
- package/lib/prompts.mjs +8 -4
- package/lib/state.mjs +30 -0
- package/lib/sticker-automation.mjs +58 -10
- package/lib/sticker-judge.mjs +179 -0
- package/lib/sticker.mjs +76 -8
- package/lib/update.mjs +265 -0
- package/package.json +2 -2
- package/prompts/duty.md +7 -2
package/index.js
CHANGED
|
@@ -50,7 +50,8 @@ import { withFileLockSync, readJsonFileSync, atomicWriteFileSync } from './lib/r
|
|
|
50
50
|
import { MEMORY_SOURCES, memoryCenterSnapshot, memoryFingerprint } from './lib/memory-center.mjs'
|
|
51
51
|
import { normalizeId, optionalId, readableId, replyTargetId } from './lib/ids.mjs'
|
|
52
52
|
import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
|
|
53
|
-
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow,
|
|
53
|
+
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow,
|
|
54
|
+
dayKey, dayResetPatch, resetDayCounters } from './lib/state.mjs'
|
|
54
55
|
import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
|
|
55
56
|
import { createLoginSession } from './lib/login-session.mjs'
|
|
56
57
|
import { modelConfigDecision, modelForceDecision } from './lib/config-policy.mjs'
|
|
@@ -109,6 +110,7 @@ import {
|
|
|
109
110
|
sniffImageFormat,
|
|
110
111
|
} from './lib/sticker.mjs'
|
|
111
112
|
import { STICKER_DEFAULTS, STICKER_LIMITS, clampStickerSettings, stickerSettings, createStickerAutomation } from './lib/sticker-automation.mjs'
|
|
113
|
+
import { judgeStickerCandidate } from './lib/sticker-judge.mjs'
|
|
112
114
|
// 🧾 每轮任务的 token 账本(2026-10-27):一次任务一条账 —— 会话精确计量、后台如实标"未计量"。
|
|
113
115
|
import {
|
|
114
116
|
tokenTaskStart, tokenTaskUpdate, tokenTaskEnd, tokenLedgerSnapshot, clearTokenLedger, tokenTaskLabel,
|
|
@@ -118,6 +120,13 @@ import {
|
|
|
118
120
|
// 编排(出分镜 → 渲染 → 收成品)在 lib/pv.mjs,真正的画面/配音在 lib/pv.py 与 lib/pv_plan.py
|
|
119
121
|
import { createPv, pvYesterday, pvDateOk } from './lib/pv.mjs'
|
|
120
122
|
import { createVideoStudio, migrateVideoSettings } from './lib/pv-session.mjs'
|
|
123
|
+
// 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」)——
|
|
124
|
+
// ⚠️ 2026-10-21 的「一键升级」曾把本机分叉整包换成 registry 版本(m17605),
|
|
125
|
+
// 所以 lib/update.mjs 里先判安装方式:只有 profile 下的 registry 安装才允许自更新。
|
|
126
|
+
import {
|
|
127
|
+
DEFAULT_REGISTRY, MIRROR_REGISTRY, PACKAGE_NAME, buildUpdateReport, checkForUpdate,
|
|
128
|
+
detectInstallMode, summarizeUpgrade, upgradeBlocker, upgradeCommand, upgradeLogLine,
|
|
129
|
+
} from './lib/update.mjs'
|
|
121
130
|
import { buildSevenDayRetrospective, buildRecoverySnapshot } from './lib/product-status.mjs'
|
|
122
131
|
|
|
123
132
|
export const name = 'dafeiyu'
|
|
@@ -153,6 +162,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
153
162
|
stickerCapacity: z.natural().default(STICKER_DEFAULTS.stickerCapacity).extra('volatile', true),
|
|
154
163
|
/** 新图先经本人批准;关闭只影响以后收的新图,不放行仍待审核的图片。 */
|
|
155
164
|
stickerReview: z.boolean().default(STICKER_DEFAULTS.stickerReview).extra('volatile', true),
|
|
165
|
+
/** 收进库之前让模型判一次「这张当表情值不值得收」;关掉就只走本地词表,且不产生淘汰用的分数。 */
|
|
166
|
+
stickerJudge: z.boolean().default(STICKER_DEFAULTS.stickerJudge).extra('volatile', true),
|
|
156
167
|
/** ⭐ 私信投递:off=只发评论(现状)· only=只私信(完全不公开评论)· both=评论 + 私信 */
|
|
157
168
|
dmMode: z.string().default('off').extra('volatile', true),
|
|
158
169
|
/** ⭐ 私信发给谁的 UID(数字)。要在 dmMode 不是 off 时填 */
|
|
@@ -236,6 +247,12 @@ export const Config = !z ? undefined : z.object({
|
|
|
236
247
|
* 留空 = 自动(从 `out`/`digest` 那个 junction 反推出工作区,落在 `<工作区>\records`);
|
|
237
248
|
* 填了就按填的来。**只复制,不动原件**;凭证 `bili_credential.json` 永远不进镜像。 */
|
|
238
249
|
mirrorDir: z.string().default('').extra('volatile', true),
|
|
250
|
+
/** 🔄 更新通道(2026-10-30):查询用哪个 registry。
|
|
251
|
+
* 留空 = 自动(默认 npmmirror,国内快;`updateMirror:false` 才走 registry.npmjs.org)。
|
|
252
|
+
* ⚠️ 和别的自由字符串一样**不做白名单校验**,只在这里声明,避免 schema 拒收整条 entry。
|
|
253
|
+
* 真正使用前 `upgradeCommand()` 还会校验必须是 http(s) 地址。 */
|
|
254
|
+
updateRegistry: z.string().default('').extra('volatile', true),
|
|
255
|
+
updateMirror: z.boolean().default(true).extra('volatile', true),
|
|
239
256
|
/** ⏱️ 出手节奏快慢系数(2026-10-21 使用者 m18802 第 1、2 条) ——
|
|
240
257
|
* `1` = 标准;`2` = 间隔翻倍(更稳、更不容易撞风控);`0.5` = 快一倍。
|
|
241
258
|
* ⚠️ 故意**不加 .min()/.max()**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ 整条插件不激活
|
|
@@ -484,7 +501,8 @@ export function apply(ctx, config) {
|
|
|
484
501
|
case 'bili_dm_inbox': return '跑去私信收件箱瞅瞅有没有人找我'
|
|
485
502
|
case 'bili_dm_thread': return `翻翻和 ${String(arg.uid || '')} 的聊天记录`
|
|
486
503
|
case 'bili_dm_reply': return `给 ${String(arg.uid || '')} 回一条私信`
|
|
487
|
-
case 'bili_reply': return `在 ${bv}
|
|
504
|
+
case 'bili_reply': return `在 ${bv} 的评论下面接话`
|
|
505
|
+
case 'bili_comment': return `在 ${bv} 下面留一句话`
|
|
488
506
|
case 'bili_reply_opus': return `在动态 ${String(arg.opus_id || '')} 下面接话`
|
|
489
507
|
case 'bili_moment': return '发一条说说~'
|
|
490
508
|
case 'bili_danmaku': return `看 ${bv} 的弹幕`
|
|
@@ -550,7 +568,7 @@ export function apply(ctx, config) {
|
|
|
550
568
|
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
|
|
551
569
|
autoReplyDm: false, dmReplyPerDay: 0, dmReplyFollowedOnly: true,
|
|
552
570
|
// 🐟 鲸鱼摸鱼模式(键必须和 Config 里的一模一样,normalize 是按 base 的键挑的)
|
|
553
|
-
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: 64, mirrorDir: '',
|
|
571
|
+
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: 64, mirrorDir: '', updateRegistry: '', updateMirror: true,
|
|
554
572
|
// ⏱️ 出手节奏系数(1 = 标准;2 = 间隔翻倍;0.5 = 快一倍)—— 2026-10-21 使用者 m18802 第 1、2 条
|
|
555
573
|
paceFactor: 1,
|
|
556
574
|
// 🎬 昨日冲浪报告视频(2026-10-22)
|
|
@@ -656,6 +674,9 @@ export function apply(ctx, config) {
|
|
|
656
674
|
if ('modelId' in p) p.modelId = String(p.modelId ?? '').trim().slice(0, 200)
|
|
657
675
|
// 📁 镜像目录:留空 = 自动(从 junction 反推);填了就是绝对路径,砍掉首尾空白、限制长度
|
|
658
676
|
if ('mirrorDir' in p) p.mirrorDir = String(p.mirrorDir ?? '').trim().slice(0, 400)
|
|
677
|
+
// 🔄 更新通道:registry 自由填(真正使用前 upgradeCommand 会校验 http(s)),镜像开关只认布尔
|
|
678
|
+
if ('updateRegistry' in p) p.updateRegistry = String(p.updateRegistry ?? '').trim().slice(0, 300)
|
|
679
|
+
if ('updateMirror' in p) p.updateMirror = p.updateMirror !== false
|
|
659
680
|
// ⏱️ 出手节奏系数:0.3–3(0 会让所有间隔归零 = 把节奏闸关掉,所以下限不给 0)
|
|
660
681
|
if ('paceFactor' in p) p.paceFactor = clampNum('paceFactor', p.paceFactor, 1)
|
|
661
682
|
// 🎬 视频:音色/时间点/目录都是自由字符串(只做长度与格式兜底,不做白名单 ——
|
|
@@ -942,7 +963,7 @@ export function apply(ctx, config) {
|
|
|
942
963
|
}
|
|
943
964
|
assertScope()
|
|
944
965
|
signal?.throwIfAborted()
|
|
945
|
-
const sending = ['bili_reply', 'bili_reply_opus', 'bili_send_message', 'bili_send_dynamic'].includes(toolName)
|
|
966
|
+
const sending = ['bili_reply', 'bili_comment', 'bili_reply_opus', 'bili_send_message', 'bili_send_dynamic'].includes(toolName)
|
|
946
967
|
const reviewed = sending ? approvedReviewExecutions.get(toolExecution.getStore()) : null
|
|
947
968
|
if (sending) {
|
|
948
969
|
for (const file of [cfgFile, dutyStateFile]) {
|
|
@@ -996,12 +1017,42 @@ export function apply(ctx, config) {
|
|
|
996
1017
|
if (!scope) { scope = new AbortController(); stickerSessionScopes.set(key, scope) }
|
|
997
1018
|
return exec.signal ? AbortSignal.any([exec.signal, scope.signal]) : scope.signal
|
|
998
1019
|
}
|
|
1020
|
+
/* 📉 2026-11-02 降噪:`collect-skipped` 里"收集区满了"是**预期内且不会自愈**的状态 —— 册子满了就一直是
|
|
1021
|
+
* 满的,直到本人 forget 一张。原来每几分钟往 duty.log 写一行,11-01 全天刷了几百条,把真正要看的东西
|
|
1022
|
+
* 埋了(使用者 m00002 让"顺手把日志里其他问题修一下")。所以这里只对"容量满"这个原因限流:
|
|
1023
|
+
* 同一个错误文本 6 小时内只写一次,且不记 act(桌宠流水里也不重复冒泡)。
|
|
1024
|
+
* 其它原因(下载失败、格式不支持、URL 不安全…)是真异常,照旧每条都写。 */
|
|
1025
|
+
const stickerSkipLoggedAt = new Map()
|
|
1026
|
+
const logStickerSkip = (value) => {
|
|
1027
|
+
const text = String(value.error || '')
|
|
1028
|
+
const capacity = value.code === 'STICKER_CAPACITY' || /收集区满了/.test(text)
|
|
1029
|
+
if (!capacity) { log(`表情自动化(collect-skipped):${text}`); return }
|
|
1030
|
+
const now = Date.now()
|
|
1031
|
+
for (const [key, at] of stickerSkipLoggedAt) if (now - at > 6 * 3600_000) stickerSkipLoggedAt.delete(key)
|
|
1032
|
+
if (stickerSkipLoggedAt.has(text)) return
|
|
1033
|
+
stickerSkipLoggedAt.set(text, now)
|
|
1034
|
+
log(`表情自动化(collect-skipped):${text}(这条 6 小时内只提示一次;想继续收就 forget 一张)`)
|
|
1035
|
+
}
|
|
999
1036
|
const stickerAutomation = createStickerAutomation({
|
|
1000
1037
|
stateDir,
|
|
1001
1038
|
getConfig: () => { refreshCfg(); return cfg },
|
|
1039
|
+
/* 🐟 2026-11-02(使用者 m00938 第 2 条):收进库之前先让宿主自己的模型看一眼这条评论,
|
|
1040
|
+
* 判"这张图当表情值不值得收",给的分数同时是**淘汰机制**的依据(见 sticker.mjs)。
|
|
1041
|
+
* 线路复用后台模型那条(backgroundModelId),不额外要密钥、不自动换模型;
|
|
1042
|
+
* 没有线路或线路报错时 judgeStickerCandidate 一律 fail open —— 照老规矩收,只是没有分数。 */
|
|
1043
|
+
judge: async ({ candidate, signal }) => {
|
|
1044
|
+
const route = String(cfg.backgroundModelId || '').trim()
|
|
1045
|
+
if (!route) return { suitable: true, score: null, description: '', tags: [], reason: '没有配置后台模型线路,这次只过本地词表', source: 'skip' }
|
|
1046
|
+
let llm = null
|
|
1047
|
+
try { llm = ctx.get('llm') } catch { llm = null }
|
|
1048
|
+
return judgeStickerCandidate({ llm, route, candidate, signal })
|
|
1049
|
+
},
|
|
1002
1050
|
onEvent: (kind, value) => {
|
|
1003
|
-
if (kind === 'collected')
|
|
1004
|
-
|
|
1051
|
+
if (kind === 'collected') {
|
|
1052
|
+
const tail = value.evicted ? `,顶掉了「${value.evicted.name}」` : ''
|
|
1053
|
+
const score = Number.isFinite(Number(value.score)) ? `(${value.score} 分)` : ''
|
|
1054
|
+
act('note', `收了一张表情「${value.name}」${score}${tail}${value.status === 'pending' ? ',等你审核后再用' : ''}`, { bvid: value.bvid })
|
|
1055
|
+
} else logStickerSkip(value)
|
|
1005
1056
|
},
|
|
1006
1057
|
})
|
|
1007
1058
|
// Only the manual gallery explicitly opts into pending. Public/tool defaults remain sendable-only.
|
|
@@ -1069,7 +1120,7 @@ export function apply(ctx, config) {
|
|
|
1069
1120
|
const browsingTools = new Set(['bili_search', 'bili_subtitle', 'bili_danmaku', 'bili_buzzwords',
|
|
1070
1121
|
'bili_video_info', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_comments',
|
|
1071
1122
|
'bili_received_replies', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments',
|
|
1072
|
-
'bili_my_video_comments', 'bili_reply', 'bili_reply_opus', 'bili_dm_inbox',
|
|
1123
|
+
'bili_my_video_comments', 'bili_reply', 'bili_comment', 'bili_reply_opus', 'bili_dm_inbox',
|
|
1073
1124
|
'bili_dm_thread', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
1074
1125
|
const tool = (spec) => {
|
|
1075
1126
|
const wrapped = {
|
|
@@ -1079,7 +1130,7 @@ export function apply(ctx, config) {
|
|
|
1079
1130
|
async execute(args, exec) {
|
|
1080
1131
|
const manuallyApproved = exec && approvedReviewExecutions.has(exec)
|
|
1081
1132
|
if (!manuallyApproved && !isFishExecution(exec)) return { ok: false, blocked: true, reason: 'mode_required', hint: 'DeepMoYu 工具仅在鲸鱼摸鱼模式内生效;普通会话不会启用角色工具或记忆。' }
|
|
1082
|
-
if (['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'].includes(spec.name)) {
|
|
1133
|
+
if (['bili_reply', 'bili_comment', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'].includes(spec.name)) {
|
|
1083
1134
|
const unauthorized = modelForceDecision(args)
|
|
1084
1135
|
if (unauthorized) return unauthorized
|
|
1085
1136
|
}
|
|
@@ -2268,7 +2319,8 @@ export function apply(ctx, config) {
|
|
|
2268
2319
|
perUser[String(uid)] = { day: today, n: base + 1 }
|
|
2269
2320
|
writeJson(dutyStateFile, {
|
|
2270
2321
|
...st,
|
|
2271
|
-
|
|
2322
|
+
// 跨天归零走统一名单(原先是手写的 `{count,picked,windows}`,漏了 moments)
|
|
2323
|
+
...(crossDay ? dayResetPatch(today) : {}),
|
|
2272
2324
|
day: today,
|
|
2273
2325
|
dmReplied: replied,
|
|
2274
2326
|
dmPerUser: perUser,
|
|
@@ -2428,9 +2480,19 @@ export function apply(ctx, config) {
|
|
|
2428
2480
|
},
|
|
2429
2481
|
})
|
|
2430
2482
|
|
|
2431
|
-
|
|
2483
|
+
/* ⭐ 2026-11-02(使用者 m00002 第 2 条原话:「在视频下面评论,要直接评论啊,不要在别人的评论下评论,
|
|
2484
|
+
* 直接视频下评论就行,更多人才能看到」):
|
|
2485
|
+
* 以前只有 `bili_reply` 一只工具,而且描述里大半篇幅在讲"回复别人的评论时 rpid/root 怎么填",
|
|
2486
|
+
* 判重那边又是"同一条视频只发一条新评论"(比回复严得多)—— 结构上就等于在推模型去楼层回复。
|
|
2487
|
+
* 真机账本也印证了:repliedComments 1941 条里 1148 条是楼中楼、只有 396 条是视频下的新评论。
|
|
2488
|
+
* 现在把"发新评论"拆成独立的一只 `bili_comment`(下面注册),没有 rpid/root 参数,
|
|
2489
|
+
* 默认动作就是"在视频下面说话";`bili_reply` 保留给"确实有人跟你说话、要接他那一句"。
|
|
2490
|
+
* 两者共用同一段实现(`replyTool.execute`),所以开关、质量、判重、节奏、审核、风控闸全都不变。 */
|
|
2491
|
+
const replyTool = {
|
|
2432
2492
|
name: 'bili_reply',
|
|
2433
|
-
description: '
|
|
2493
|
+
description: '在视频下方**回复某一条已有的评论**(楼中楼/楼层回复)。正文简短,按当前会话的语气自然接话。\n'
|
|
2494
|
+
+ '⭐ **想给这条视频留言、想让更多人看见 ⇒ 用 `bili_comment`(发新评论),不要用这只。**'
|
|
2495
|
+
+ '这只只在"有人先跟你说话了、你要接他那一句"时用:楼层回复只有对方和点进楼里的人看得到。\n'
|
|
2434
2496
|
+ '回复别人的评论时:`rpid` 填他那条的 rpid,`root` 填你自己那条主评论的 rpid(`bili_received_replies` 的 '
|
|
2435
2497
|
+ '`how_to_reply` 直接照抄就行)。没有条数上限,但**回过的不再回**(宿主记着账本)。\n'
|
|
2436
2498
|
+ '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回 —— '
|
|
@@ -2452,11 +2514,14 @@ export function apply(ctx, config) {
|
|
|
2452
2514
|
picture: { type: 'string', description: '(可选,仅发新评论)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查;回复楼层不支持图片' },
|
|
2453
2515
|
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2454
2516
|
},
|
|
2455
|
-
|
|
2517
|
+
/* `presetRpid` 是 `bili_comment` 用的:传 '0' 就锁定"发新评论"这条分支。
|
|
2518
|
+
⚠️ 必须复用**同一个 args 对象**(不要 `{...args}`)—— 审批路径靠 `approvedReviewArgs` 这个
|
|
2519
|
+
WeakMap 认身份,复制一份就等于把"预览时那张图的字节指纹"丢了。 */
|
|
2520
|
+
async execute(args, exec, presetRpid) {
|
|
2456
2521
|
const bvid = String(args.bvid ?? '').trim()
|
|
2457
|
-
const rpid = optionalId(args.rpid)
|
|
2522
|
+
const rpid = presetRpid === undefined ? optionalId(args.rpid) : String(presetRpid)
|
|
2458
2523
|
const isReply = rpid !== '0'
|
|
2459
|
-
const root = optionalId(args.root)
|
|
2524
|
+
const root = isReply ? optionalId(args.root) : '0'
|
|
2460
2525
|
/* 🔴 2026-10-20:模型拿着「动态(opus)下面的评论」来调这只 —— 那儿根本没有 BV 号。
|
|
2461
2526
|
* 原来不校验,直接往下传,MCP 抛「bvid 提供错误,必须是以 BV 开头的…12 位字符串」,
|
|
2462
2527
|
* 报错暴露在流水里像插件坏了;更要紧的是它会被当成"这条回不了"而放弃,
|
|
@@ -2725,6 +2790,39 @@ export function apply(ctx, config) {
|
|
|
2725
2790
|
if (riskNote && out && typeof out === 'object') return { ...out, riskNote }
|
|
2726
2791
|
return out
|
|
2727
2792
|
},
|
|
2793
|
+
}
|
|
2794
|
+
// 回复通道(楼中楼):只在"有人先跟你说话"时用
|
|
2795
|
+
tool(replyTool)
|
|
2796
|
+
|
|
2797
|
+
/* 🗣️ 发新评论(= 直接发在视频下面,所有人点开视频都能看见)——
|
|
2798
|
+
* 使用者 m00002 第 2 条要的就是这个动作,所以把它独立成一只**没有 rpid/root 参数**的工具:
|
|
2799
|
+
* 模型想留言时不必再拐进"回复某人"的语义里,参数面也不给它走错的机会。 */
|
|
2800
|
+
tool({
|
|
2801
|
+
name: 'bili_comment',
|
|
2802
|
+
description: '🗣️ **在视频下方发一条新评论** —— 直接挂在视频下面,点开视频的人都能看见。'
|
|
2803
|
+
+ '想让更多人看到你的话,就是用这只(不要用 bili_reply 去回复别人,那只有对方看得见)。\n'
|
|
2804
|
+
+ '什么时候发:看完这条视频真有话说就发(哪一下、哪一秒、哪个说法打动你,或者你不同意哪儿)。'
|
|
2805
|
+
+ '**没有想说的就不发 —— 配额不是催办;同一条视频只发一条新评论**(发过就换下一条视频)。\n'
|
|
2806
|
+
+ '✍️ **内容要有东西,不是为发而发**:光喊「awsl / 太强了 / 哈哈哈哈」这类话会被打回。'
|
|
2807
|
+
+ '按当前会话的语气自然说话,不要求固定词、表情或自称。\n'
|
|
2808
|
+
+ '🖼️ 想配图就传 `picture`(表情库里的名字,`bili_sticker({action:"list"})` 查,最多 2 张);'
|
|
2809
|
+
+ '小表情码如 [doge] 直接写进正文就行,不算图。\n'
|
|
2810
|
+
+ '⏱️ 发之前先 `bili_pace({kind:"comment"})` 问一句 —— 说等就等,别试探性重发(那是 -352 的最短路径)。\n'
|
|
2811
|
+
+ '🔴 安全闸:桌宠设置「自动化」里的「自动评论视频」**默认是关的**;关着的时候这个工具会拒绝 —— '
|
|
2812
|
+
+ '需要本人在工作台手动打开权限;模型不能用 force 或配置 PATCH 绕过。\n'
|
|
2813
|
+
+ '📌 动态(opus)下面没有 BV 号,那种要用 `bili_reply_opus`。',
|
|
2814
|
+
parameters: {
|
|
2815
|
+
bvid: { type: 'string', description: 'BV号(视频下方发评论)' },
|
|
2816
|
+
text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
|
|
2817
|
+
picture: { type: 'string', description: '(可选)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查' },
|
|
2818
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2819
|
+
},
|
|
2820
|
+
async execute(args) {
|
|
2821
|
+
// 复用回复工具的实现:`presetRpid='0'` 强制走"发新评论"分支,
|
|
2822
|
+
// 质量/判重/节奏/审核/风控闸全都是同一套,不另起一条会跑偏的路。
|
|
2823
|
+
// ⚠️ 传**原对象**(不是副本):审批路径用 `approvedReviewArgs` WeakMap 认身份。
|
|
2824
|
+
return replyTool.execute(args, undefined, '0')
|
|
2825
|
+
},
|
|
2728
2826
|
})
|
|
2729
2827
|
|
|
2730
2828
|
tool({
|
|
@@ -2943,6 +3041,7 @@ export function apply(ctx, config) {
|
|
|
2943
3041
|
if (text.length > 300) return { ok: false, error: `正文 ${text.length} 字,B站动态上限约 300 字,先砍短` }
|
|
2944
3042
|
const gateOn = Boolean(cfg.autoMoment)
|
|
2945
3043
|
if (!gateOn && args?.force !== true) {
|
|
3044
|
+
log('🚫 说说被拦:工作台「自动发说说」未开启')
|
|
2946
3045
|
return {
|
|
2947
3046
|
ok: false, blocked: true,
|
|
2948
3047
|
hint: '工作台中的「自动发说说」未开启,请本人手动打开。模型不能通过 force 或配置 PATCH 自授权限。',
|
|
@@ -2953,9 +3052,13 @@ export function apply(ctx, config) {
|
|
|
2953
3052
|
const cap = Number.isFinite(capNum) ? Math.max(0, capNum) : 1
|
|
2954
3053
|
const used = countMomentsToday()
|
|
2955
3054
|
if (cap <= 0 && args?.force !== true) {
|
|
3055
|
+
// ⚠️ 2026-11-02:以前这几种拦截**只回给模型、不写日志** —— 于是 duty.log 里
|
|
3056
|
+
// "动态怎么不发了"完全查不到线索(连一条 blocked 都没有)。拦截也是事件,必须留痕。
|
|
3057
|
+
log(`🚫 说说被拦:工作台的每日上限是 0(不发)`)
|
|
2956
3058
|
return { ok: false, blocked: true, hint: '工作台中的动态每日上限是 0(不发),请本人手动调整;模型不能绕过额度。' }
|
|
2957
3059
|
}
|
|
2958
3060
|
if (used >= cap && args?.force !== true) {
|
|
3061
|
+
log(`🚫 说说被拦:今天已发 ${used}/${cap} 条(额度满),明天再聊`)
|
|
2959
3062
|
return { ok: false, blocked: true, hint: `今天已经发了 ${used}/${cap} 条说说了,明天再聊。` }
|
|
2960
3063
|
}
|
|
2961
3064
|
/* ⏱️ 出手节奏(2026-10-21 m18802 第 1、2 条):发动态是最"显眼"的动作 ——
|
|
@@ -3083,7 +3186,7 @@ export function apply(ctx, config) {
|
|
|
3083
3186
|
* `sendReview` 只对鱼模式的自动发送生效。默认关闭,旧发送语义不变。
|
|
3084
3187
|
* 队列落在 state.json,重启后仍能看到待处理内容;批准时重新走原工具,
|
|
3085
3188
|
* 进程内 WeakMap 只通过预览排队这一层,原开关、质量、判重、节奏和风控闸都不跳过。 */
|
|
3086
|
-
const SEND_REVIEW_TOOLS = new Set(['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
3189
|
+
const SEND_REVIEW_TOOLS = new Set(['bili_reply', 'bili_comment', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
3087
3190
|
const SEND_REVIEW_MAX = 80
|
|
3088
3191
|
const approvedReviewArgs = new WeakMap()
|
|
3089
3192
|
const approvedReviewExecutions = new WeakMap()
|
|
@@ -3142,11 +3245,13 @@ export function apply(ctx, config) {
|
|
|
3142
3245
|
try { clean = validateToolArguments(toolInputSchema(backgroundToolRegistry.get(toolName)?.parameters || {}), reviewArgs(args)) }
|
|
3143
3246
|
catch (error) { return { ok: false, blocked: true, error: String(error?.message ?? error) } }
|
|
3144
3247
|
if (JSON.stringify(clean).length > 16000) return { ok: false, blocked: true, error: '预览参数过长,未保存内容' }
|
|
3145
|
-
|
|
3248
|
+
// `bili_comment` 是 `bili_reply` 的"只发新评论"通道,picture 语义完全一致,一起走这段
|
|
3249
|
+
const pictureTool = toolName === 'bili_reply' || toolName === 'bili_comment'
|
|
3250
|
+
if (pictureTool && !Object.hasOwn(clean, 'picture')) clean.picture = ''
|
|
3146
3251
|
const now = Date.now()
|
|
3147
3252
|
const id = reviewIdOf(toolName, clean, now)
|
|
3148
3253
|
const attachments = []
|
|
3149
|
-
if (
|
|
3254
|
+
if (pictureTool && clean.picture) {
|
|
3150
3255
|
const names = String(clean.picture).split(/[,,、]+|\s+/).filter(Boolean)
|
|
3151
3256
|
if (names.length > 2) return { ok: false, blocked: true, error: '发送预览最多选择 2 张表情' }
|
|
3152
3257
|
for (const name of names) {
|
|
@@ -3385,8 +3490,10 @@ export function apply(ctx, config) {
|
|
|
3385
3490
|
*/
|
|
3386
3491
|
const bumpState = (patch) => withStateLock(() => {
|
|
3387
3492
|
const st = readJson(dutyStateFile, {})
|
|
3388
|
-
|
|
3389
|
-
|
|
3493
|
+
// ⚠️ 跨天归零只走 lib/state.mjs 那一份名单(`resetDayCounters`)——
|
|
3494
|
+
// 这里原先手写 `count/picked/windows/tokensToday`,**漏了 moments**,
|
|
3495
|
+
// 导致跨天后 `moments` 还是昨天的数 ⇒ `bili_moment` 静默判定"今天额度满了"、永不再发(2026-11-02 的事故)。
|
|
3496
|
+
if (st.day !== dayKey()) resetDayCounters(st)
|
|
3390
3497
|
writeJson(dutyStateFile, { ...st, ...(typeof patch === 'function' ? patch(st) : patch) })
|
|
3391
3498
|
return st
|
|
3392
3499
|
})
|
|
@@ -3773,7 +3880,105 @@ export function apply(ctx, config) {
|
|
|
3773
3880
|
return { command: 'dsh', args: [], shell: true, fallback: true, hint }
|
|
3774
3881
|
}
|
|
3775
3882
|
|
|
3776
|
-
|
|
3883
|
+
/* ═══════════════ 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」)═══════════════
|
|
3884
|
+
* ⚠️ **这一节有一道不可删的闸**:2026-10-21 的「一键升级」直接跑 `dsh plugin add`,
|
|
3885
|
+
* 把本机「目录联接 → 源码」的分叉整包换成了 registry 版本,改动全没(m17605,恢复花一整轮)。
|
|
3886
|
+
* 所以顺序永远是:**先判安装方式 → 再判有没有在跑 → 才允许升级**;
|
|
3887
|
+
* 本机链接/源码/未知安装一律只检查、不升级,把原因明说给使用者。
|
|
3888
|
+
* 检查走 npm registry 的 packument(只读、有超时和体积上限),不碰 GitHub API 限额。
|
|
3889
|
+
*/
|
|
3890
|
+
let upgrading = false
|
|
3891
|
+
const updateLogFile = join(stateDir, 'update.log')
|
|
3892
|
+
const appendUpdate = (line) => { try { appendFileSync(updateLogFile, line + '\n', 'utf8') } catch { /* 写不进去也不影响升级 */ } }
|
|
3893
|
+
/** 上一次检查结果(界面轮询 /status 时复用,不重复联网);10 分钟内有效。 */
|
|
3894
|
+
let updateCache = { at: 0, value: null }
|
|
3895
|
+
let updateCheckedAt = 0
|
|
3896
|
+
const updateRegistry = () => {
|
|
3897
|
+
const explicit = String(cfg.updateRegistry || '').trim()
|
|
3898
|
+
if (explicit) return explicit
|
|
3899
|
+
// 中国网络:默认按 npmmirror 查,避免 registry.npmjs.org 慢到 8 秒超时
|
|
3900
|
+
return String(cfg.updateMirror === false ? DEFAULT_REGISTRY : MIRROR_REGISTRY)
|
|
3901
|
+
}
|
|
3902
|
+
const updateBusy = () => ({
|
|
3903
|
+
installing,
|
|
3904
|
+
asr: installingAsr,
|
|
3905
|
+
feiyu: Boolean(feiyuLive?.size),
|
|
3906
|
+
background: Boolean(backgroundRun?.active),
|
|
3907
|
+
video: Boolean(pv.status()?.running),
|
|
3908
|
+
})
|
|
3909
|
+
const runUpdateCheck = async ({ force = false } = {}) => {
|
|
3910
|
+
if (!force && updateCache.value && Date.now() - updateCache.at < 10 * 60_000) return updateCache.value
|
|
3911
|
+
let report
|
|
3912
|
+
try {
|
|
3913
|
+
report = await checkForUpdate({
|
|
3914
|
+
current: myVersion(),
|
|
3915
|
+
pluginDir,
|
|
3916
|
+
dshHome: String(ctx?.get?.('dshHomePath')?.('') || process.env.DSH_HOME || ''),
|
|
3917
|
+
registry: updateRegistry(),
|
|
3918
|
+
packageName: PACKAGE_NAME,
|
|
3919
|
+
})
|
|
3920
|
+
} catch (error) {
|
|
3921
|
+
// 检查自己坏了也不能让设置页 500 —— 如实说"查不了"
|
|
3922
|
+
report = { ...buildUpdateReport({ current: myVersion(), published: null, install: detectInstallMode({ pluginDir, dshHome: String(ctx?.get?.('dshHomePath')?.('') || process.env.DSH_HOME || '') }), registry: updateRegistry(), error: String(error?.message || error) }), code: 'CHECK_FAILED' }
|
|
3923
|
+
}
|
|
3924
|
+
updateCache = { at: Date.now(), value: report }
|
|
3925
|
+
updateCheckedAt = Date.now()
|
|
3926
|
+
return report
|
|
3927
|
+
}
|
|
3928
|
+
const updateStatus = () => ({
|
|
3929
|
+
current: myVersion(),
|
|
3930
|
+
...(updateCache.value || {}),
|
|
3931
|
+
upgrading,
|
|
3932
|
+
checkedAt: updateCheckedAt || 0,
|
|
3933
|
+
})
|
|
3934
|
+
const startUpgrade = async () => {
|
|
3935
|
+
const report = updateCache.value || await runUpdateCheck()
|
|
3936
|
+
const blocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
|
|
3937
|
+
// ⚠️ 顺序不能反:`updateStatus()` 里带着检查结果的 `ok:true`,
|
|
3938
|
+
// 必须把它展开在**前面**,再写本次升级的 ok/code/error,否则失败会被覆盖成成功。
|
|
3939
|
+
if (blocker) return { ...updateStatus(), ok: false, code: 'UPGRADE_BLOCKED', error: blocker }
|
|
3940
|
+
const dsh = dshCliCached()
|
|
3941
|
+
const command = upgradeCommand({
|
|
3942
|
+
dshCli: dsh, profile: report.install?.profile, packageName: PACKAGE_NAME,
|
|
3943
|
+
electron: Boolean(process.versions.electron), registry: report.registry,
|
|
3944
|
+
})
|
|
3945
|
+
if (command.ok === false) return { ...updateStatus(), ok: false, code: command.code, error: command.error }
|
|
3946
|
+
upgrading = true
|
|
3947
|
+
appendUpdate(upgradeLogLine({ phase: 'start', detail: `${report.current} → ${report.latest}(profile ${command.profile})` }))
|
|
3948
|
+
// 升级要几十秒,HTTP 不能一直挂着:起完就返回,进度看 /update?rev 或日志
|
|
3949
|
+
const child = spawn(command.command, command.args, {
|
|
3950
|
+
cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
3951
|
+
env: uvEnv({ ...command.env, BILI_DUTY_HOME: stateDir }),
|
|
3952
|
+
})
|
|
3953
|
+
const chunks = []
|
|
3954
|
+
const collect = (buf) => { if (chunks.length < 400) chunks.push(String(buf)); appendUpdate(upgradeLogLine({ phase: 'out', detail: String(buf).trim() })) }
|
|
3955
|
+
child.stdout?.on('data', collect)
|
|
3956
|
+
child.stderr?.on('data', collect)
|
|
3957
|
+
let finished = false
|
|
3958
|
+
const timer = setTimeout(() => { if (!finished) { try { child.kill() } catch { /* 已经退出 */ } } }, 5 * 60_000)
|
|
3959
|
+
timer?.unref?.()
|
|
3960
|
+
child.on('error', (error) => {
|
|
3961
|
+
if (finished) return
|
|
3962
|
+
finished = true; clearTimeout(timer); upgrading = false
|
|
3963
|
+
const result = summarizeUpgrade({ code: -1, output: String(error?.message || error) })
|
|
3964
|
+
appendUpdate(upgradeLogLine({ phase: 'error', detail: result.message }))
|
|
3965
|
+
log(`🔄 升级进程起不来:${result.message}`)
|
|
3966
|
+
})
|
|
3967
|
+
child.on('close', (code) => {
|
|
3968
|
+
if (finished) return
|
|
3969
|
+
finished = true; clearTimeout(timer); upgrading = false
|
|
3970
|
+
const result = summarizeUpgrade({ code: Number(code ?? -1), output: chunks.join(''), latest: report.latest })
|
|
3971
|
+
appendUpdate(upgradeLogLine({ phase: result.ok ? 'done' : 'error', detail: result.message }))
|
|
3972
|
+
log(`🔄 升级${result.ok ? '完成' : '失败'}:${result.message}`)
|
|
3973
|
+
act('note', '更新通道:' + result.message)
|
|
3974
|
+
// 安装目录变了 ⇒ 旧结果作废,下一次检查重新问 registry
|
|
3975
|
+
updateCache = { at: 0, value: null }
|
|
3976
|
+
})
|
|
3977
|
+
return { ok: true, started: true, from: report.current, to: report.latest, profile: command.profile, hint: '升级在后台进行,完成后需要完全重启 DSH', ...updateStatus() }
|
|
3978
|
+
}
|
|
3979
|
+
ctx.effect(() => () => { updateCache = { at: 0, value: null } }, 'bili-duty: 收更新检查缓存')
|
|
3980
|
+
|
|
3981
|
+
|
|
3777
3982
|
// 🐾 2026-10-26 桌宠化改造:签名加 opts —— 桌宠那条 /pet/start 会把「这一轮的 token 预算」
|
|
3778
3983
|
// 递进来(headless 子进程没有鲸鱼摸鱼那套 token 保险丝,预算只能写进提示词让模型自己见好就收)。
|
|
3779
3984
|
// 现有调用者不传参(opts.tokenBudget 为空 ⇒ 一行都不多),行为与从前完全一致。
|
|
@@ -5076,30 +5281,43 @@ export function apply(ctx, config) {
|
|
|
5076
5281
|
} catch { return { ok: false, hh: 0, mm: 0, wd: '', part: '', text: '', line: '' } }
|
|
5077
5282
|
}
|
|
5078
5283
|
|
|
5079
|
-
/** 今天发了几条说说(从 state.json 数;宿主记账,不靠模型自觉)
|
|
5284
|
+
/** 今天发了几条说说(从 state.json 数;宿主记账,不靠模型自觉)
|
|
5285
|
+
* ⚠️ 2026-11-02 加固:`moments` 只跟 `st.day` 绑在一起是不够的 —— 只要有**任何**一条跨天写路径
|
|
5286
|
+
* 没重置它(当时 `bumpState`、私信记账就都漏了),今天就会读到昨天的数、然后永久"额度已满"。
|
|
5287
|
+
* 现在再加一道自证的 `momentsDay`:记账时一起写,读的时候对不上就按 0 算、顺手把陈数清掉。
|
|
5288
|
+
*/
|
|
5080
5289
|
const countMomentsToday = (now = new Date()) => {
|
|
5081
5290
|
const st = readJson(dutyStateFile, {})
|
|
5082
|
-
|
|
5291
|
+
const today = dayKey(now)
|
|
5292
|
+
if (st.day !== today) return 0
|
|
5293
|
+
// 老状态没有 momentsDay:那 `moments` 可能是翻篇前留下的陈数 —— 只有在 day 也不对时才可疑,
|
|
5294
|
+
// 这里 day 已经对上了,就信 `momentsDay`;没有 momentsDay 时退回看 lastMomentAt 是不是今天。
|
|
5295
|
+
const stamp = typeof st.momentsDay === 'string' ? st.momentsDay : ''
|
|
5296
|
+
if (stamp) return stamp === today ? Number(st.moments || 0) : 0
|
|
5297
|
+
const at = Date.parse(String(st.lastMomentAt || ''))
|
|
5298
|
+
if (Number.isFinite(at) && dayKey(new Date(at)) !== today) return 0
|
|
5299
|
+
return Number(st.moments || 0)
|
|
5083
5300
|
}
|
|
5084
5301
|
/**
|
|
5085
5302
|
* 记说说账(delta 可正可负)。⚠️ 走 stateLock(`bili_moment` 里是 await 发出去之后才记账,中间能插进别的写)。
|
|
5086
5303
|
* ⭐ 2026-10-26 改成可加可减:`bili_moment` 现在**发送前先在锁内预占一个名额**(并发不会再双发突破上限),
|
|
5087
5304
|
* 发送失败再用 -1 把名额退回去;force(不占额)成功后仍走 +1 补记。
|
|
5088
|
-
* ⚠️ 跨天时必须连 count/picked/windows
|
|
5089
|
-
*
|
|
5090
|
-
* countPickedToday() 把昨天的额度算到今天头上 ⇒ 今天一开局就"额度已满"」。
|
|
5305
|
+
* ⚠️ 跨天时必须连 count/picked/windows/moments 一起重置 —— 现在统一走 lib/state.mjs 的 `dayResetPatch`,
|
|
5306
|
+
* 那边是**唯一**的当日计数名单,别再在这里手写一份(手写漏字段就是 2026-11-02 那次事故)。
|
|
5091
5307
|
*/
|
|
5092
5308
|
const bumpMomentBy = (delta = 1) => withStateLock(() => {
|
|
5093
5309
|
const st = readJson(dutyStateFile, {})
|
|
5094
5310
|
const today = dayKey()
|
|
5095
|
-
|
|
5096
|
-
const
|
|
5311
|
+
// 陈数(没有 momentsDay 或对不上今天)一律当 0 起算,绝不把昨天的额度带进今天
|
|
5312
|
+
const stale = st.momentsDay !== today || st.day !== today
|
|
5313
|
+
const base = stale ? 0 : Number(st.moments || 0)
|
|
5097
5314
|
const next = Math.max(0, base + delta)
|
|
5098
5315
|
writeJson(dutyStateFile, {
|
|
5099
5316
|
...st,
|
|
5100
|
-
...(
|
|
5317
|
+
...(st.day !== today ? dayResetPatch(today) : {}),
|
|
5101
5318
|
day: today,
|
|
5102
5319
|
moments: next,
|
|
5320
|
+
momentsDay: today,
|
|
5103
5321
|
lastMomentAt: new Date().toISOString(),
|
|
5104
5322
|
})
|
|
5105
5323
|
return next
|
|
@@ -6963,6 +7181,8 @@ export function apply(ctx, config) {
|
|
|
6963
7181
|
'backgroundWindowStart', 'backgroundWindowEnd',
|
|
6964
7182
|
// 📁 记录镜像目录(留空 = 自动)
|
|
6965
7183
|
'mirrorDir',
|
|
7184
|
+
// 🔄 更新通道(2026-10-30)—— 漏表 = 设置页改了却存不上
|
|
7185
|
+
'updateRegistry', 'updateMirror',
|
|
6966
7186
|
// 🎬 视频日报那组(clampPatch 都逐个处理了,这里不认就永远存不上)
|
|
6967
7187
|
'pvAuto', 'pvAt', 'pvVoice', 'pvDir',
|
|
6968
7188
|
// 制作整条视频的模型;旧 pvModelId 只在读取配置时迁移。
|
|
@@ -7254,18 +7474,29 @@ export function apply(ctx, config) {
|
|
|
7254
7474
|
return sendJson(res, 200, result)
|
|
7255
7475
|
}
|
|
7256
7476
|
|
|
7257
|
-
/*
|
|
7258
|
-
*
|
|
7259
|
-
*
|
|
7260
|
-
*
|
|
7261
|
-
*
|
|
7262
|
-
*
|
|
7263
|
-
*
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7477
|
+
/* 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」;取代 2026-10-21 删掉的那两条)
|
|
7478
|
+
*
|
|
7479
|
+
* 2026-10-21 删掉的是 `POST /upgrade`(宿主直接跑 `dsh plugin add`)+ `GET /update`(问 npm/GitHub),
|
|
7480
|
+
* 因为那次点击把本机「目录联接 → 源码」的分叉整包换成了 registry 版本(m17605)。
|
|
7481
|
+
* 现在恢复,但**顺序反过来**:先由 lib/update.mjs 判定这份安装到底是不是 registry 安装,
|
|
7482
|
+
* 不是就只回检查结果 + 原因,canUpgrade=false,前端连按钮都禁用。
|
|
7483
|
+
* 另外两个闸:有刷站/后台/渲染在跑时拒绝;同一时间只允许一个升级进程。 */
|
|
7484
|
+
if (req.method === 'GET' && path === '/api/bili-duty/update') {
|
|
7485
|
+
const force = url.searchParams.get('force') === '1'
|
|
7486
|
+
const report = await runUpdateCheck({ force })
|
|
7487
|
+
const rev = String(Math.round(Number(updateCheckedAt) / 1000)) + ':' + (report.latest || '') + ':' + (upgrading ? 'u' : '-') + ':' + myVersion()
|
|
7488
|
+
if (url.searchParams.get('rev') === rev) return sendJson(res, 200, { ok: true, rev, unchanged: true })
|
|
7489
|
+
return sendJson(res, 200, { ...updateStatus(), rev, blocker: upgradeBlocker({ report, busy: updateBusy(), upgrading }) })
|
|
7490
|
+
}
|
|
7491
|
+
if (req.method === 'POST' && path === '/api/bili-duty/upgrade') {
|
|
7492
|
+
const body = await readBody(req)
|
|
7493
|
+
// 只接受"确认升级"这一个动作;任何额外字段(目标版本、路径、参数)一律忽略,
|
|
7494
|
+
// 避免把这条路由变成可执行任意 dsh 命令的入口。
|
|
7495
|
+
if (body && typeof body === 'object' && body.confirm !== true) {
|
|
7496
|
+
return sendJson(res, 400, { ok: false, code: 'UPGRADE_CONFIRM_REQUIRED', error: '升级需要显式确认(confirm: true)' })
|
|
7497
|
+
}
|
|
7498
|
+
return sendJson(res, 200, await startUpgrade())
|
|
7499
|
+
}
|
|
7269
7500
|
|
|
7270
7501
|
// 「测一下」:真跑一次,回答"能不能用"
|
|
7271
7502
|
if (req.method === 'POST' && path === '/api/bili-duty/check') {
|
package/lib/background-tools.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export function backgroundToolNames(cfg = {}) {
|
|
3
3
|
const names = ['bili_whoami', 'bili_search', 'bili_subtitle', 'bili_comments', 'bili_video_info', 'bili_danmaku', 'bili_buzzwords', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_seen', 'bili_say', 'bili_pace', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments', 'bili_my_video_comments', 'bili_learn', 'bili_soul', 'bili_taste', 'bili_records', 'bili_sticker']
|
|
4
4
|
if (cfg.autoComment || cfg.replyToComments) names.push('bili_reply', 'bili_reply_opus')
|
|
5
|
+
// 「自动评论视频」开着才给发新评论的通道;回复另算(replyToComments)
|
|
6
|
+
if (cfg.autoComment) names.push('bili_comment')
|
|
5
7
|
if (cfg.replyToComments) names.push('bili_received_replies')
|
|
6
8
|
if (cfg.autoMoment) names.push('bili_moment')
|
|
7
9
|
if (cfg.autoReplyDm) names.push('bili_dm_inbox', 'bili_dm_thread', 'bili_dm_reply')
|
package/lib/model-relay.mjs
CHANGED
|
@@ -82,8 +82,22 @@ export function createModelRelay({ getLlm, onError } = {}) {
|
|
|
82
82
|
return jsonReply(res, 200, { value })
|
|
83
83
|
} finally { release() }
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
/* 🔴 2026-11-02 修:以前 `resolve` 也抢 `grant.busy`,于是「问一下这条线路解析成什么模型」和
|
|
86
|
+
* 紧跟着的流式请求会互相撞车 —— 谁后到谁拿 409 RELAY_BUSY。日志里 2026-10-30 两次、11-01 一次、
|
|
87
|
+
* 11-02 一次 learn-review 全是这么挂的(`后台进度(…,请求0):后台进程报告错误(RELAY_BUSY)`),
|
|
88
|
+
* 表现成"后台任务莫名其妙失败",可实际上一次模型都还没跑。
|
|
89
|
+
* 现在只有真正占着连接的长请求(stream)才拿 busy,而且撞车时先等一会儿再判,不一上来就失败:
|
|
90
|
+
* `resolve` 只是元数据查询,几十毫秒就回,并发几次不会互相干扰。 */
|
|
91
|
+
const isStream = body.action === 'stream'
|
|
92
|
+
if (isStream) {
|
|
93
|
+
const deadlineAt = Date.now() + 30_000
|
|
94
|
+
while (grant.busy) {
|
|
95
|
+
abort.signal.throwIfAborted()
|
|
96
|
+
if (Date.now() > deadlineAt) throw fail('上一个响应还没有结束,这次请求先没发出去;稍等再试', 'RELAY_BUSY')
|
|
97
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, 50); timer.unref?.() })
|
|
98
|
+
}
|
|
99
|
+
grant.busy = true; modelRequest = true
|
|
100
|
+
}
|
|
87
101
|
const llm = getLlm?.()
|
|
88
102
|
if (typeof llm?.resolveModelInfo !== 'function' || typeof llm?.prepareCall !== 'function') throw fail('宿主模型服务尚未就绪,未切换到其他线路', 'RELAY_NO_LLM')
|
|
89
103
|
const route = grant.route
|
|
@@ -137,7 +151,18 @@ export function createModelRelay({ getLlm, onError } = {}) {
|
|
|
137
151
|
open(model, { expiresAt, onUsage, onEvent, tools = [] } = {}) {
|
|
138
152
|
if (closed || startupError || !server.listening) throw fail('后台模型桥接尚未就绪,请稍后重试;不会自动换模型', 'RELAY_NOT_READY')
|
|
139
153
|
const route = splitModelRoute(model), llm = getLlm?.()
|
|
140
|
-
if (!llm?.listProviders?.().some(provider => provider.id === route.provider))
|
|
154
|
+
if (!llm?.listProviders?.().some(provider => provider.id === route.provider)) {
|
|
155
|
+
/* 📌 2026-11-02:这条错误在日志里出现过 5 次(10-27 两次、10-28、10-30、11-02),每次都是
|
|
156
|
+
* 后台模型写成 `buddy/...` 而宿主根本没连这个线路 —— 光说"没注册"用户不知道下一步做什么。
|
|
157
|
+
* 这里把宿主**当前真有哪些线路**列出来,照着一个填进设置就能开跑。 */
|
|
158
|
+
let have = []
|
|
159
|
+
try { have = (llm?.listProviders?.() || []).map(provider => String(provider?.id || '')).filter(Boolean) } catch { /* 保持空 */ }
|
|
160
|
+
throw fail(`后台模型线路「${route.provider}」在这个宿主里没有注册,任务没有开跑。`
|
|
161
|
+
+ (have.length
|
|
162
|
+
? `当前可用的线路是:${have.join('、')} —— 去工作台把「后台模型」改成其中之一,或先把这个线路连上。`
|
|
163
|
+
: '宿主目前一个模型线路都没连上,请先在原生设置里连好线路。')
|
|
164
|
+
+ '不会自动换模型。', 'NO_ADAPTER')
|
|
165
|
+
}
|
|
141
166
|
const token = randomBytes(32).toString('hex')
|
|
142
167
|
const grant = { token, route, expiresAt: Number(expiresAt) || Date.now() + 45 * 60_000, onUsage, onEvent, tools: new Map(tools.map(tool => [tool.name, tool])), toolQueue: Promise.resolve(), busy: false, controllers: new Set() }
|
|
143
168
|
grants.set(token, grant)
|
package/lib/persona.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { captureMemorySnapshot } from './records.mjs'
|
|
|
36
36
|
* 不是每句堆自称、呜呜和身体动作;引用例句会很快变成模板,所以这里故意只写规则。
|
|
37
37
|
*/
|
|
38
38
|
export const DAFEIYU_REPLY_GUIDE = Object.freeze([
|
|
39
|
+
'想对一条视频说点什么,就发在视频下面(bili_comment)——那是发言,点开视频的人都看得见;楼层回复只有对方看得见,别拿它当默认开口方式。',
|
|
39
40
|
'先接住对方或内容里最具体的那一下,再说自己的反应;不要先报身份、总结流程或夸一串空话。',
|
|
40
41
|
'公开评论和回复通常一两句、短而有内容:可以好奇、吐槽、嘴硬或轻轻关心,反应要和眼前细节相称。',
|
|
41
42
|
'回复别人时直接接他刚说的意思;私信先顾及对话上下文和对方情绪,不把私聊改写成公开播报。',
|