dsh-deepmoyu 2.3.0 → 2.4.2
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 +93 -153
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +143 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1404 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +1095 -285
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/update.mjs +265 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- package/tests/verify_pv_sample.py +0 -91
package/index.js
CHANGED
|
@@ -23,30 +23,37 @@ const hostDeps = await loadHostDeps()
|
|
|
23
23
|
const z = hostDeps.z
|
|
24
24
|
const defineTool = hostDeps.defineTool
|
|
25
25
|
import { spawn } from 'node:child_process'
|
|
26
|
-
import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { appendFileSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { createRequire } from 'node:module'
|
|
29
29
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
30
|
-
import { createHash } from 'node:crypto'
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
31
31
|
import { fileURLToPath } from 'node:url'
|
|
32
32
|
import { McpClient } from './lib/mcp.mjs'
|
|
33
|
-
import { resolveSessionPreset, personaScope, CHARACTER_COMPASS } from './lib/mode.mjs'
|
|
34
|
-
import {
|
|
33
|
+
import { resolveSessionPreset, personaScope, isFeiyuGoal, CHARACTER_COMPASS } from './lib/mode.mjs'
|
|
34
|
+
import { createToolScopeRegistry } from './lib/tool-scope.mjs'
|
|
35
|
+
import { buildFeiyuObjective, buildLiveBrief, MOMENT_OPENING_KEEP, interactionStyleGuide } from './lib/prompts.mjs'
|
|
35
36
|
import { RUN_DEFAULTS, RUN_LIMITS, migrateRunSettings, clampRunSettings, resolveRunOptions, scheduleRunDeadline, backgroundWindowActive, backgroundWindowEndAt, backgroundWindowLaunch } from './lib/run-settings.mjs'
|
|
36
37
|
import { AUTOMATION_DEFAULTS, AUTOMATION_LIMITS, clampAutomationSettings, dailyScheduleStatus, markDailyStarted, releaseDailyStart } from './lib/automation.mjs'
|
|
38
|
+
import { COMPANION_DEFAULTS, clampCompanionSettings, companionPolicy, companionPrompt, nextChatterAt } from './lib/companion.mjs'
|
|
37
39
|
import { createPetSession } from './lib/pet-session.mjs'
|
|
38
40
|
import { mutateLearningStateSync } from './lib/learn-store.mjs'
|
|
39
41
|
import { learningReviewBatch, learningReviewKey, learningReviewProgress, buildLearningReviewPrompt, learningReviewRetryAllowed, summarizeLearningReview } from './lib/learn-review.mjs'
|
|
40
42
|
import { createModelRelay, RELAY_PROVIDER, splitModelRoute } from './lib/model-relay.mjs'
|
|
41
|
-
import { backgroundToolNames, exposeBackgroundTools } from './lib/background-tools.mjs'
|
|
43
|
+
import { backgroundToolNames, exposeBackgroundTools, toolInputSchema, validateToolArguments } from './lib/background-tools.mjs'
|
|
42
44
|
import { redactRecordText, sanitizeRecord } from './lib/record-privacy.mjs'
|
|
43
45
|
import { createBackgroundOutput } from './lib/background-output.mjs'
|
|
46
|
+
import { openDirectory } from './lib/open-directory.mjs'
|
|
47
|
+
import { inspectMainEnvironment, inspectAsrEnvironment, MAIN_PYTHON_RANGE, PYTHON_COMPATIBILITY_CHECK, MAIN_RUNTIME_CHECK, ASR_RUNTIME_CHECK } from './lib/python-env.mjs'
|
|
44
48
|
import { appendActivityRecord, readActivityTail, rotateActivityLog, importActivityFile, importArchivedActivityChunk, captureMemorySnapshot, appendLearningRecord, readRecordEvents } from './lib/records.mjs'
|
|
45
|
-
import { withFileLockSync, readJsonFileSync } from './lib/record-storage.mjs'
|
|
49
|
+
import { withFileLockSync, readJsonFileSync, atomicWriteFileSync } from './lib/record-storage.mjs'
|
|
50
|
+
import { MEMORY_SOURCES, memoryCenterSnapshot, memoryFingerprint } from './lib/memory-center.mjs'
|
|
46
51
|
import { normalizeId, optionalId, readableId, replyTargetId } from './lib/ids.mjs'
|
|
47
52
|
import { verifyRecent, appendVerifyToReport } from './lib/verify.mjs'
|
|
48
53
|
import { WINDOWS, stateDirOf, readJson, writeJson, ensureHomeFiles, ensureCharter, charterPath, inWindow, dayKey } from './lib/state.mjs'
|
|
49
54
|
import { loginStart, loginPoll, saveCredential, renderQrPng } from './lib/login.mjs'
|
|
55
|
+
import { createLoginSession } from './lib/login-session.mjs'
|
|
56
|
+
import { modelConfigDecision, modelForceDecision } from './lib/config-policy.mjs'
|
|
50
57
|
import { ensureUv } from './lib/uvfetch.mjs'
|
|
51
58
|
// 💬 私信判据(该不该回这条)只此一份:收件箱标注、发送前复核、测试都调它
|
|
52
59
|
import { dmSkipReason, dmRemember, dmSentToday as dmSentTodayOf } from './lib/dm.mjs'
|
|
@@ -91,13 +98,15 @@ import {
|
|
|
91
98
|
// 这里只负责"发之前问一句、发成功记一笔、被风控记冷却"。
|
|
92
99
|
import {
|
|
93
100
|
PACE, PACE_KINDS, RISK_COOLDOWN, QUOTA_CODES, quotaBucketOf,
|
|
94
|
-
paceBook, paceCheck, paceTake, paceNoteRisk, paceSummary, paceHint,
|
|
101
|
+
paceBook, paceCheck, paceTake, paceNoteRisk, paceSummary, paceExplain, paceHint,
|
|
95
102
|
} from './lib/pace.mjs'
|
|
96
103
|
// 🖼️ 表情包(2026-10-22 使用者 m02275「回复评论的时候可以多用上表情包」)——
|
|
97
104
|
// 内置区 = 使用者的「大肥鱼形象图\大肥鱼表情」(只读),收集区 = 家目录 stickers/(鱼自己收)。
|
|
98
105
|
// resolve 只出**库内**绝对路径,collect 只收 http(s) 或库内文件 —— 不当下载器,不碰任意路径。
|
|
99
106
|
import {
|
|
100
|
-
listStickers, collectSticker, uploadSticker, forgetSticker, resolveSticker, collectedDirOf, readStickerImage,
|
|
107
|
+
listStickers, collectSticker, uploadSticker, forgetSticker, resolveSticker, collectedDirOf, readStickerImage, approveSticker, rejectSticker,
|
|
108
|
+
snapshotStickerForSend,
|
|
109
|
+
sniffImageFormat,
|
|
101
110
|
} from './lib/sticker.mjs'
|
|
102
111
|
import { STICKER_DEFAULTS, STICKER_LIMITS, clampStickerSettings, stickerSettings, createStickerAutomation } from './lib/sticker-automation.mjs'
|
|
103
112
|
// 🧾 每轮任务的 token 账本(2026-10-27):一次任务一条账 —— 会话精确计量、后台如实标"未计量"。
|
|
@@ -108,6 +117,15 @@ import {
|
|
|
108
117
|
// 🎬 「昨日冲浪报告」视频(2026-10-22 使用者要求)——
|
|
109
118
|
// 编排(出分镜 → 渲染 → 收成品)在 lib/pv.mjs,真正的画面/配音在 lib/pv.py 与 lib/pv_plan.py
|
|
110
119
|
import { createPv, pvYesterday, pvDateOk } from './lib/pv.mjs'
|
|
120
|
+
import { createVideoStudio, migrateVideoSettings } from './lib/pv-session.mjs'
|
|
121
|
+
// 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」)——
|
|
122
|
+
// ⚠️ 2026-10-21 的「一键升级」曾把本机分叉整包换成 registry 版本(m17605),
|
|
123
|
+
// 所以 lib/update.mjs 里先判安装方式:只有 profile 下的 registry 安装才允许自更新。
|
|
124
|
+
import {
|
|
125
|
+
DEFAULT_REGISTRY, MIRROR_REGISTRY, PACKAGE_NAME, buildUpdateReport, checkForUpdate,
|
|
126
|
+
detectInstallMode, summarizeUpgrade, upgradeBlocker, upgradeCommand, upgradeLogLine,
|
|
127
|
+
} from './lib/update.mjs'
|
|
128
|
+
import { buildSevenDayRetrospective, buildRecoverySnapshot } from './lib/product-status.mjs'
|
|
111
129
|
|
|
112
130
|
export const name = 'dafeiyu'
|
|
113
131
|
export const inject = ['webServer', 'tools', 'settings']
|
|
@@ -130,6 +148,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
130
148
|
night: z.boolean().default(true).extra('volatile', true),
|
|
131
149
|
/** 自动评论 @ 使用者(默认关 —— 会拿用户的号说话,必须他自己开) */
|
|
132
150
|
autoComment: z.boolean().default(false).extra('volatile', true),
|
|
151
|
+
/** 鱼模式的公开发送先落本地待审核队列;默认关闭,保持历史直发行为。 */
|
|
152
|
+
sendReview: z.boolean().default(false).extra('volatile', true),
|
|
133
153
|
/** 本地采图与新评论配图各自控制;不会打开评论权限。概率为 0–100,冷却单位秒。 */
|
|
134
154
|
stickerAutoCollect: z.boolean().default(STICKER_DEFAULTS.stickerAutoCollect).extra('volatile', true),
|
|
135
155
|
stickerCollectProbability: z.natural().default(STICKER_DEFAULTS.stickerCollectProbability).extra('volatile', true),
|
|
@@ -138,10 +158,12 @@ export const Config = !z ? undefined : z.object({
|
|
|
138
158
|
stickerSendProbability: z.natural().default(STICKER_DEFAULTS.stickerSendProbability).extra('volatile', true),
|
|
139
159
|
stickerSendCooldown: z.natural().default(STICKER_DEFAULTS.stickerSendCooldown).extra('volatile', true),
|
|
140
160
|
stickerCapacity: z.natural().default(STICKER_DEFAULTS.stickerCapacity).extra('volatile', true),
|
|
161
|
+
/** 新图先经本人批准;关闭只影响以后收的新图,不放行仍待审核的图片。 */
|
|
162
|
+
stickerReview: z.boolean().default(STICKER_DEFAULTS.stickerReview).extra('volatile', true),
|
|
141
163
|
/** ⭐ 私信投递:off=只发评论(现状)· only=只私信(完全不公开评论)· both=评论 + 私信 */
|
|
142
164
|
dmMode: z.string().default('off').extra('volatile', true),
|
|
143
165
|
/** ⭐ 私信发给谁的 UID(数字)。要在 dmMode 不是 off 时填 */
|
|
144
|
-
dmUid: z.natural().default(0).extra('volatile', true),
|
|
166
|
+
dmUid: z.union([z.natural(), z.string()]).default(0).extra('volatile', true),
|
|
145
167
|
/** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
|
|
146
168
|
transcribe: z.boolean().default(false).extra('volatile', true),
|
|
147
169
|
/** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
|
|
@@ -203,8 +225,10 @@ export const Config = !z ? undefined : z.object({
|
|
|
203
225
|
autoMoment: z.boolean().default(false).extra('volatile', true),
|
|
204
226
|
/** 每天最多发几条说说 */
|
|
205
227
|
momentPerDay: z.natural().default(1).extra('volatile', true),
|
|
206
|
-
/**
|
|
207
|
-
momentStyle: z.string().default('
|
|
228
|
+
/** 说说的口味:随心情(默认,不加任何倾向)/ 感想 / 吐槽 / 安利 / 冷知识 —— 只当软提示,不是模板 */
|
|
229
|
+
momentStyle: z.string().default('随心情').extra('volatile', true),
|
|
230
|
+
/** 鲸鱼的互动档位:只调整陪伴方式,不改变任何发送权限。 */
|
|
231
|
+
interactionStyle: z.string().default('companion').extra('volatile', true),
|
|
208
232
|
/** 指定模型(`provider/model`;**留空 = 用当前对话的模型**) */
|
|
209
233
|
modelId: z.string().default('').extra('volatile', true),
|
|
210
234
|
/** token 预算(0 = 不限;**这一次开跑之后**烧的量超了就自动停,不会把号刷爆) */
|
|
@@ -219,6 +243,12 @@ export const Config = !z ? undefined : z.object({
|
|
|
219
243
|
* 留空 = 自动(从 `out`/`digest` 那个 junction 反推出工作区,落在 `<工作区>\records`);
|
|
220
244
|
* 填了就按填的来。**只复制,不动原件**;凭证 `bili_credential.json` 永远不进镜像。 */
|
|
221
245
|
mirrorDir: z.string().default('').extra('volatile', true),
|
|
246
|
+
/** 🔄 更新通道(2026-10-30):查询用哪个 registry。
|
|
247
|
+
* 留空 = 自动(默认 npmmirror,国内快;`updateMirror:false` 才走 registry.npmjs.org)。
|
|
248
|
+
* ⚠️ 和别的自由字符串一样**不做白名单校验**,只在这里声明,避免 schema 拒收整条 entry。
|
|
249
|
+
* 真正使用前 `upgradeCommand()` 还会校验必须是 http(s) 地址。 */
|
|
250
|
+
updateRegistry: z.string().default('').extra('volatile', true),
|
|
251
|
+
updateMirror: z.boolean().default(true).extra('volatile', true),
|
|
222
252
|
/** ⏱️ 出手节奏快慢系数(2026-10-21 使用者 m18802 第 1、2 条) ——
|
|
223
253
|
* `1` = 标准;`2` = 间隔翻倍(更稳、更不容易撞风控);`0.5` = 快一倍。
|
|
224
254
|
* ⚠️ 故意**不加 .min()/.max()**:schema 校验不过 ⇒ cordis 拒收这条 entry ⇒ 整条插件不激活
|
|
@@ -238,6 +268,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
238
268
|
pvVoice: z.string().default('zh-CN-XiaoyiNeural').extra('volatile', true),
|
|
239
269
|
/** 视频放哪儿(留空 = 自动:反推出使用者工作区,落在工作区根) */
|
|
240
270
|
pvDir: z.string().default('').extra('volatile', true),
|
|
271
|
+
/** 制作整条视频的会话模型(provider/model;留空跟随来源会话或宿主默认模型)。 */
|
|
272
|
+
videoModelId: z.string().default('').extra('volatile', true),
|
|
241
273
|
/* ── 🎙 真·本鱼音色(GPT-SoVITS 零样本克隆)──────────────────────────
|
|
242
274
|
* 三件套都给齐了才走 GPT-SoVITS,缺一个就自动退回上面的 edge-tts ——
|
|
243
275
|
* 没装这套环境的机器照样能出片,不会因为音色做不了而整条卡死。 */
|
|
@@ -259,6 +291,13 @@ export const Config = !z ? undefined : z.object({
|
|
|
259
291
|
petShow: z.boolean().default(true).extra('volatile', true),
|
|
260
292
|
/* 🫧 空闲时的闲聊泡泡(低频自言自语;false = 安静模式) */
|
|
261
293
|
idleChatter: z.boolean().default(true).extra('volatile', true),
|
|
294
|
+
chatterFrequency: z.string().default('normal').extra('volatile', true),
|
|
295
|
+
notificationLevel: z.string().default('normal').extra('volatile', true),
|
|
296
|
+
focusMode: z.boolean().default(false).extra('volatile', true),
|
|
297
|
+
quietHoursEnabled: z.boolean().default(false).extra('volatile', true),
|
|
298
|
+
quietHoursStart: z.string().default('23:00').extra('volatile', true),
|
|
299
|
+
quietHoursEnd: z.string().default('08:00').extra('volatile', true),
|
|
300
|
+
petReducedMotion: z.boolean().default(false).extra('volatile', true),
|
|
262
301
|
sessionModelId: z.string().default('').extra('volatile', true),
|
|
263
302
|
sessionTokenBudget: z.natural().default(0).extra('volatile', true),
|
|
264
303
|
sessionMaxRounds: z.natural().default(64).extra('volatile', true),
|
|
@@ -320,29 +359,13 @@ export function apply(ctx, config) {
|
|
|
320
359
|
|
|
321
360
|
// ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
|
|
322
361
|
// (2026-10-15:只看目录,害一位真实用户"显示就绪但其实装失败"白折腾 ✗)
|
|
323
|
-
const sitePkgs = (venvRoot) => {
|
|
324
|
-
const win = join(venvRoot, 'Lib', 'site-packages')
|
|
325
|
-
if (existsSync(win)) return win
|
|
326
|
-
try {
|
|
327
|
-
const lib = join(venvRoot, 'lib')
|
|
328
|
-
for (const d of readdirSync(lib)) {
|
|
329
|
-
if (!d.startsWith('python')) continue
|
|
330
|
-
const p = join(lib, d, 'site-packages')
|
|
331
|
-
if (existsSync(p)) return p
|
|
332
|
-
}
|
|
333
|
-
} catch { /* 没有就算了 */ }
|
|
334
|
-
return win
|
|
335
|
-
}
|
|
336
|
-
const hasPkg = (venvRoot, prefix) => {
|
|
337
|
-
try { return readdirSync(sitePkgs(venvRoot)).some((n) => n.startsWith(prefix)) } catch { return false }
|
|
338
|
-
}
|
|
339
362
|
// ⚠️ 2026-10-18 用户实测:徽章显示「环境已就绪」、B站也登录了,可一跑就 `ModuleNotFoundError: No module named 'mcp'`
|
|
340
363
|
// ⇒ 原判据**只查了 bilibili_api**(0.1.2 修"假就绪"时留下的口子)⇒ 缺 `mcp` / `qrcode` 也照样说就绪 ✗
|
|
341
364
|
// ⇒ 三个都得有:bilibili_api(查数据)· mcp(跑那一轮的 MCP 服务器)· qrcode(画登录二维码)
|
|
342
|
-
const
|
|
343
|
-
const missingVenvDeps = () =>
|
|
344
|
-
const venvReadyNow = () =>
|
|
345
|
-
const asrReadyNow = () =>
|
|
365
|
+
const mainEnvironment = () => inspectMainEnvironment(join(stateDir, '.venv'))
|
|
366
|
+
const missingVenvDeps = () => mainEnvironment().reasons
|
|
367
|
+
const venvReadyNow = () => mainEnvironment().ready
|
|
368
|
+
const asrReadyNow = () => asrEnvironment().ready
|
|
346
369
|
const cfgFile = join(stateDir, 'config.json')
|
|
347
370
|
/**
|
|
348
371
|
* 本地优先(2026-10-18,两个用户实证):DSH 0.2.1 某些启动方式下 ctx.settings.update 会抛
|
|
@@ -531,30 +554,45 @@ export function apply(ctx, config) {
|
|
|
531
554
|
* ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
|
|
532
555
|
*/
|
|
533
556
|
const DEFAULTS = {
|
|
557
|
+
...COMPANION_DEFAULTS,
|
|
534
558
|
...RUN_DEFAULTS,
|
|
535
559
|
...AUTOMATION_DEFAULTS,
|
|
536
560
|
...STICKER_DEFAULTS,
|
|
537
|
-
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
|
|
561
|
+
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, sendReview: false, transcribe: false, asrModel: 'small',
|
|
538
562
|
dmMode: 'off', dmUid: 0,
|
|
539
563
|
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
|
|
540
564
|
autoReplyDm: false, dmReplyPerDay: 0, dmReplyFollowedOnly: true,
|
|
541
565
|
// 🐟 鲸鱼摸鱼模式(键必须和 Config 里的一模一样,normalize 是按 base 的键挑的)
|
|
542
|
-
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '
|
|
566
|
+
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: 64, mirrorDir: '', updateRegistry: '', updateMirror: true,
|
|
543
567
|
// ⏱️ 出手节奏系数(1 = 标准;2 = 间隔翻倍;0.5 = 快一倍)—— 2026-10-21 使用者 m18802 第 1、2 条
|
|
544
568
|
paceFactor: 1,
|
|
545
569
|
// 🎬 昨日冲浪报告视频(2026-10-22)
|
|
546
|
-
pvAuto: false, pvAt: '10:30', pvVoice: 'zh-CN-XiaoyiNeural', pvDir: '',
|
|
570
|
+
pvAuto: false, pvAt: '10:30', pvVoice: 'zh-CN-XiaoyiNeural', pvDir: '', videoModelId: '',
|
|
547
571
|
// 🎙 真·本鱼音色(GPT-SoVITS 零样本克隆;三项齐全才启用,否则退回 edge-tts)
|
|
548
572
|
pvGsvRepo: '', pvGsvRef: '', pvGsvPrompt: '', pvGsvPython: '',
|
|
549
573
|
// 🐾 桌宠显隐(2026-10-26 桌宠化改造):默认展示;false = 桌宠菜单里点了「隐藏桌宠」,
|
|
550
574
|
// 设置页还有开关能唤回 —— 所以这个值必须走配置对账那套(loadCfg 按 DEFAULTS 的键挑)
|
|
551
575
|
petShow: true,
|
|
552
576
|
}
|
|
577
|
+
const configuredUid = value => {
|
|
578
|
+
const id = normalizeId(typeof value === 'string' && !value.trim() ? '0' : value)
|
|
579
|
+
const small = Number(id)
|
|
580
|
+
// Preserve compatible legacy safe integers; large decimal strings never become numeric identities.
|
|
581
|
+
return Number.isSafeInteger(small) ? small : id
|
|
582
|
+
}
|
|
583
|
+
let invalidRecipientWarned = false
|
|
553
584
|
const normalize = (src, base) => {
|
|
554
585
|
const out = { ...base }
|
|
555
586
|
for (const k of Object.keys(base)) {
|
|
556
587
|
const v = src?.[k]
|
|
557
588
|
if (v === undefined || v === null) continue
|
|
589
|
+
if (k === 'dmUid') {
|
|
590
|
+
try { out[k] = configuredUid(v) } catch {
|
|
591
|
+
out[k] = 0
|
|
592
|
+
if (!invalidRecipientWarned) { invalidRecipientWarned = true; log('配置的收件 UID 无效或已丢失精度,已暂停成果投递;请重新填写原始十进制字符串。') }
|
|
593
|
+
}
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
558
596
|
if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
|
|
559
597
|
else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
|
|
560
598
|
else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
|
|
@@ -610,6 +648,7 @@ export function apply(ctx, config) {
|
|
|
610
648
|
if ('perDay' in p) p.perDay = clampNum('perDay', p.perDay, 0)
|
|
611
649
|
if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
|
|
612
650
|
if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
|
|
651
|
+
if ('sendReview' in p) p.sendReview = Boolean(p.sendReview)
|
|
613
652
|
if ('replyPerRound' in p) p.replyPerRound = clampNum('replyPerRound', p.replyPerRound, 1)
|
|
614
653
|
if ('commentPerRound' in p) p.commentPerRound = clampNum('commentPerRound', p.commentPerRound, 0)
|
|
615
654
|
// 💬 自动回私信:防的是「陌生人私信当聊天通道刷屏」和「一条都不回」两个极端
|
|
@@ -619,7 +658,9 @@ export function apply(ctx, config) {
|
|
|
619
658
|
// 🐟 鲸鱼摸鱼模式
|
|
620
659
|
if ('autoMoment' in p) p.autoMoment = Boolean(p.autoMoment)
|
|
621
660
|
if ('momentPerDay' in p) p.momentPerDay = clampNum('momentPerDay', p.momentPerDay, 0)
|
|
622
|
-
|
|
661
|
+
// 说说口味:白名单外一律回落「随心情」——那是"不加倾向",比强塞一个口味更安全
|
|
662
|
+
if ('momentStyle' in p) p.momentStyle = ['随心情', '感想', '吐槽', '安利', '冷知识'].includes(String(p.momentStyle)) ? String(p.momentStyle) : '随心情'
|
|
663
|
+
if ('interactionStyle' in p) p.interactionStyle = ['quiet', 'companion', 'creator', 'steward'].includes(String(p.interactionStyle)) ? String(p.interactionStyle) : 'companion'
|
|
623
664
|
if ('tokenBudget' in p) p.tokenBudget = clampNum('tokenBudget', p.tokenBudget, 0)
|
|
624
665
|
if ('totalTokenBudget' in p) p.totalTokenBudget = clampNum('totalTokenBudget', p.totalTokenBudget, 0)
|
|
625
666
|
if ('maxRounds' in p) p.maxRounds = clampNum('maxRounds', p.maxRounds, 1)
|
|
@@ -628,6 +669,9 @@ export function apply(ctx, config) {
|
|
|
628
669
|
if ('modelId' in p) p.modelId = String(p.modelId ?? '').trim().slice(0, 200)
|
|
629
670
|
// 📁 镜像目录:留空 = 自动(从 junction 反推);填了就是绝对路径,砍掉首尾空白、限制长度
|
|
630
671
|
if ('mirrorDir' in p) p.mirrorDir = String(p.mirrorDir ?? '').trim().slice(0, 400)
|
|
672
|
+
// 🔄 更新通道:registry 自由填(真正使用前 upgradeCommand 会校验 http(s)),镜像开关只认布尔
|
|
673
|
+
if ('updateRegistry' in p) p.updateRegistry = String(p.updateRegistry ?? '').trim().slice(0, 300)
|
|
674
|
+
if ('updateMirror' in p) p.updateMirror = p.updateMirror !== false
|
|
631
675
|
// ⏱️ 出手节奏系数:0.3–3(0 会让所有间隔归零 = 把节奏闸关掉,所以下限不给 0)
|
|
632
676
|
if ('paceFactor' in p) p.paceFactor = clampNum('paceFactor', p.paceFactor, 1)
|
|
633
677
|
// 🎬 视频:音色/时间点/目录都是自由字符串(只做长度与格式兜底,不做白名单 ——
|
|
@@ -643,6 +687,13 @@ export function apply(ctx, config) {
|
|
|
643
687
|
}
|
|
644
688
|
if ('pvVoice' in p) p.pvVoice = String(p.pvVoice ?? '').trim().slice(0, 100) || 'zh-CN-XiaoyiNeural'
|
|
645
689
|
if ('pvDir' in p) p.pvDir = String(p.pvDir ?? '').trim().slice(0, 400)
|
|
690
|
+
if ('videoModelId' in p) {
|
|
691
|
+
const want = String(p.videoModelId ?? '').trim()
|
|
692
|
+
if (want.length > 200 || want && (want.indexOf('/') <= 0 || want.indexOf('/') === want.length - 1 || /\s/.test(want))) {
|
|
693
|
+
throw new Error('制作模型要写成 provider/model;留空跟随当前会话或宿主默认模型')
|
|
694
|
+
}
|
|
695
|
+
p.videoModelId = want
|
|
696
|
+
}
|
|
646
697
|
// 🎙 真·本鱼音色:三个字符串都是路径/原文,只剪长度(路径可能很长)
|
|
647
698
|
if ('pvGsvRepo' in p) p.pvGsvRepo = String(p.pvGsvRepo ?? '').trim().slice(0, 400)
|
|
648
699
|
if ('pvGsvRef' in p) p.pvGsvRef = String(p.pvGsvRef ?? '').trim().slice(0, 400)
|
|
@@ -650,11 +701,11 @@ export function apply(ctx, config) {
|
|
|
650
701
|
if ('pvGsvPython' in p) p.pvGsvPython = String(p.pvGsvPython ?? '').trim().slice(0, 400)
|
|
651
702
|
// 🐾 桌宠显隐:纯布尔,给什么是什么(桌宠菜单/设置页两处都可能写它)
|
|
652
703
|
if ('petShow' in p) p.petShow = Boolean(p.petShow)
|
|
653
|
-
return clampStickerSettings(clampAutomationSettings(clampRunSettings(p)))
|
|
704
|
+
return clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p))))
|
|
654
705
|
}
|
|
655
706
|
|
|
656
|
-
const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
|
|
657
|
-
const savedCfg = () => normalize(migrateRunSettings(readJson(cfgFile, {}), hostCfg()), { ...DEFAULTS })
|
|
707
|
+
const hostCfg = () => normalize(migrateVideoSettings(config), { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
|
|
708
|
+
const savedCfg = () => normalize(migrateRunSettings(migrateVideoSettings(readJson(cfgFile, {})), hostCfg()), { ...DEFAULTS })
|
|
658
709
|
/**
|
|
659
710
|
* ⭐⭐ 0.1.11(2026-10-19 · 第三批用户报"开关还是回弹"之后改的)
|
|
660
711
|
* 0.1.9 / 0.1.10 一直想"跟宿主对账":宿主那边有值就以宿主为准 —— 但 DSH 0.2.1 的某些启动方式下
|
|
@@ -672,7 +723,7 @@ export function apply(ctx, config) {
|
|
|
672
723
|
let hostDiff = [] // 插件这份和 DSH 原生设置那份不一样、且以插件为准的键
|
|
673
724
|
let lastDiffSig = '' // 上一次记过日志的组合(同一种组合只写一行)
|
|
674
725
|
let touchedKeys = (() => {
|
|
675
|
-
const raw = readJson(cfgFile, {})
|
|
726
|
+
const raw = migrateVideoSettings(readJson(cfgFile, {}))
|
|
676
727
|
if (Array.isArray(raw._set)) return new Set(raw._set)
|
|
677
728
|
return new Set(Object.keys(DEFAULTS).filter((k) => k in raw && JSON.stringify(raw[k]) !== JSON.stringify(DEFAULTS[k])))
|
|
678
729
|
})()
|
|
@@ -706,11 +757,11 @@ export function apply(ctx, config) {
|
|
|
706
757
|
const row = settingsDesc()
|
|
707
758
|
// ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
|
|
708
759
|
if (!row) return clampPatch(saved)
|
|
709
|
-
const user = row.user || {}
|
|
760
|
+
const user = migrateVideoSettings(row.user || {})
|
|
710
761
|
// ⭐ 2026-10-19(另一位用户的 dsh 给出的更彻底的一招):**别用闭包里那份 `config` 当宿主基准** ——
|
|
711
762
|
// 桌面版里它是挂载时的快照,之后写进 `cordis.patch.yml` 的值不会热刷新进来 ✗
|
|
712
763
|
// ⇒ 用 `describe()` 这一行里的 `value`(当前 patch 解析出来的现值)当基准,拿不到才退回 `config` ✓
|
|
713
|
-
const out = normalize(row.value && typeof row.value === 'object' ? row.value : config, { ...DEFAULTS })
|
|
764
|
+
const out = normalize(migrateVideoSettings(row.value && typeof row.value === 'object' ? row.value : config), { ...DEFAULTS })
|
|
714
765
|
// ⚠️ 2026-10-18(用户实证 + 他的 dsh 定位):`pythonPath` 是**装依赖时自动写的**,
|
|
715
766
|
// 但它一旦在 DSH 原生设置里有过值(默认 "python"),就会被宿主覆盖回来 ⇒
|
|
716
767
|
// 「重装依赖」日志写着"已把 pythonPath 设为 venv",实际生效的还是系统那个空壳 python
|
|
@@ -750,16 +801,19 @@ export function apply(ctx, config) {
|
|
|
750
801
|
// ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
|
|
751
802
|
const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
|
|
752
803
|
const saveCfg = (patch) => {
|
|
753
|
-
Object.assign(cfg, normalize(patch, cfg))
|
|
754
|
-
// ⭐ 记下"这些键由插件面板说了算"(0.1.11:loadCfg 拿它决定谁赢)
|
|
755
|
-
for (const k of Object.keys(patch || {})) if (k in DEFAULTS) touchedKeys.add(k)
|
|
756
804
|
/* ⭐ 拿文件锁写(2026-10-26):设置页是逐字段 PATCH 的,两个输入框并发保存时
|
|
757
805
|
* 读-改-写会互相覆盖(后一次把前一次的 _set 抹掉 ⇒ 那批键的裁决权被还给宿主 ⇒ "开关弹回")。
|
|
758
806
|
* 锁内同时把盘上已有的 _set 并进来 —— 只信内存那份会把别的进程刚记的键丢掉。 */
|
|
759
807
|
withFileLockSync(cfgFile, () => {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
808
|
+
const current = migrateVideoSettings(readJsonFileSync(cfgFile, {}))
|
|
809
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('CONFIG_JSON_INVALID: expected an object')
|
|
810
|
+
const next = normalize(patch, normalize(current, cfg))
|
|
811
|
+
const keys = new Set([...(Array.isArray(current._set) ? current._set : []), ...touchedKeys,
|
|
812
|
+
...Object.keys(patch || {})].filter(key => Object.hasOwn(DEFAULTS, key)))
|
|
813
|
+
writeJson(cfgFile, { ...current, ...next, _set: [...keys] })
|
|
814
|
+
// Publish the in-memory result only after the durable transaction succeeds.
|
|
815
|
+
Object.assign(cfg, next)
|
|
816
|
+
touchedKeys = keys
|
|
763
817
|
})
|
|
764
818
|
}
|
|
765
819
|
/**
|
|
@@ -803,6 +857,11 @@ export function apply(ctx, config) {
|
|
|
803
857
|
* 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
|
|
804
858
|
*/
|
|
805
859
|
const persistCfg = async (patch) => {
|
|
860
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw Object.assign(new Error('配置 PATCH 必须是对象'), { status: 400, code: 'CONFIG_INPUT' })
|
|
861
|
+
for (const key of Object.keys(patch)) {
|
|
862
|
+
if (!Object.hasOwn(DEFAULTS, key)) throw Object.assign(new Error('未知配置字段:' + key), { status: 400, code: 'CONFIG_INPUT' })
|
|
863
|
+
if (typeof DEFAULTS[key] === 'boolean' && typeof patch[key] !== 'boolean') throw Object.assign(new Error(key + ' 必须是 true 或 false,不能使用字符串或数字'), { status: 400, code: 'CONFIG_INPUT' })
|
|
864
|
+
}
|
|
806
865
|
const clean = clampPatch(patch)
|
|
807
866
|
/* ⭐ 2026-10-20(m11206):钳制不再静默 —— 把「你要的」和「实际存下的」摆一起报给界面。
|
|
808
867
|
* 原来 perDay 填 100 被钳成 50,界面还回一句「已保存」,使用者只能靠肉眼发现「还是 50」。 */
|
|
@@ -813,6 +872,9 @@ export function apply(ctx, config) {
|
|
|
813
872
|
if (typeof clean[k] === 'number' && Number(want) !== clean[k]) clamped.push({ key: k, want, got: clean[k] })
|
|
814
873
|
}
|
|
815
874
|
saveCfg(clean)
|
|
875
|
+
if (Object.hasOwn(clean, 'chatterFrequency') || clean.idleChatter === true) {
|
|
876
|
+
await bumpState({ petChatterAt: nextChatterAt(cfg) })
|
|
877
|
+
}
|
|
816
878
|
const host = await saveCfgHost(clean)
|
|
817
879
|
// 宿主写成功 ⇒ 恢复对账;失败 ⇒ 记成"本地优先",别让界面把它们压回去
|
|
818
880
|
// ⭐ 无条件本地优先(2026-10-18 第二次修):
|
|
@@ -889,9 +951,15 @@ export function apply(ctx, config) {
|
|
|
889
951
|
}
|
|
890
952
|
|
|
891
953
|
const call = async (toolName, args, opts) => {
|
|
892
|
-
const
|
|
954
|
+
const execution = toolExecution.getStore()
|
|
955
|
+
const signal = execution?.signal
|
|
956
|
+
const assertScope = () => {
|
|
957
|
+
if (execution?.agent && !approvedReviewExecutions.has(execution) && !isFishExecution(execution)) throw Object.assign(new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,停止本次工具后续操作'), { code: 'MODE_REQUIRED' })
|
|
958
|
+
}
|
|
959
|
+
assertScope()
|
|
893
960
|
signal?.throwIfAborted()
|
|
894
961
|
const sending = ['bili_reply', 'bili_reply_opus', 'bili_send_message', 'bili_send_dynamic'].includes(toolName)
|
|
962
|
+
const reviewed = sending ? approvedReviewExecutions.get(toolExecution.getStore()) : null
|
|
895
963
|
if (sending) {
|
|
896
964
|
for (const file of [cfgFile, dutyStateFile]) {
|
|
897
965
|
const value = readJsonFileSync(file, {})
|
|
@@ -900,14 +968,31 @@ export function apply(ctx, config) {
|
|
|
900
968
|
}
|
|
901
969
|
const client = await ensureMcp()
|
|
902
970
|
signal?.throwIfAborted()
|
|
971
|
+
assertScope()
|
|
972
|
+
if (reviewed) {
|
|
973
|
+
refreshCfg()
|
|
974
|
+
const row = reviewed.row
|
|
975
|
+
const publicOk = cfg.dmMode !== 'only'
|
|
976
|
+
const allowed = row.tool === 'bili_deliver' ? ['both', 'only'].includes(cfg.dmMode) && readableId(cfg.dmUid) === row.targetUid
|
|
977
|
+
: row.tool === 'bili_dm_reply' ? Boolean(cfg.autoReplyDm)
|
|
978
|
+
: row.tool === 'bili_moment' ? Boolean(cfg.autoMoment)
|
|
979
|
+
: publicOk && Boolean(optionalId(row.args.rpid) === '0' ? cfg.autoComment : cfg.replyToComments)
|
|
980
|
+
if (!allowed) throw Object.assign(new Error('批准后发送权限已关闭或收件人已改变,内容没有提交'), { reviewBeforeSubmit: true })
|
|
981
|
+
reviewed.submitted = true
|
|
982
|
+
}
|
|
903
983
|
const result = await client.call(toolName, args, signal ? { ...opts, signal } : opts)
|
|
904
984
|
// A completed send must still be recorded even if stop arrived during IO.
|
|
905
985
|
// Reads may not resume a stopped wrapper and submit a new send afterwards.
|
|
906
|
-
if (!sending) signal?.throwIfAborted()
|
|
986
|
+
if (!sending) { signal?.throwIfAborted(); assertScope() }
|
|
907
987
|
return result
|
|
908
988
|
}
|
|
909
989
|
|
|
910
990
|
const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
|
|
991
|
+
const loginSession = createLoginSession({ start: loginStart, poll: loginPoll, save: credential => {
|
|
992
|
+
const execution = toolExecution.getStore()
|
|
993
|
+
if (execution?.agent && !isFishExecution(execution)) throw new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,未替换登录凭证')
|
|
994
|
+
return saveCredential(stateDir, credential)
|
|
995
|
+
} })
|
|
911
996
|
|
|
912
997
|
/* 表情采集独立队列;图片读取、遍历只在表情功能需要时发生,不进入 /status。 */
|
|
913
998
|
// goals.pause does not cancel in-flight tool IO. Keep each session's collection scope separate.
|
|
@@ -931,19 +1016,26 @@ export function apply(ctx, config) {
|
|
|
931
1016
|
stateDir,
|
|
932
1017
|
getConfig: () => { refreshCfg(); return cfg },
|
|
933
1018
|
onEvent: (kind, value) => {
|
|
934
|
-
if (kind === 'collected') act('note', `收了一张表情「${value.name}
|
|
1019
|
+
if (kind === 'collected') act('note', `收了一张表情「${value.name}」${value.status === 'pending' ? ',等你审核后再用' : ''}`, { bvid: value.bvid })
|
|
935
1020
|
else log(`表情自动化(${kind}):${value.error || ''}`)
|
|
936
1021
|
},
|
|
937
1022
|
})
|
|
938
|
-
|
|
939
|
-
|
|
1023
|
+
// Only the manual gallery explicitly opts into pending. Public/tool defaults remain sendable-only.
|
|
1024
|
+
const stickerGallery = (status = 'approved') => {
|
|
1025
|
+
const all = listStickers(stateDir, { status: 'all' })
|
|
1026
|
+
const visible = status === 'all' ? all : status === 'pending' ? all.filter(item => item.status === 'pending') : listStickers(stateDir)
|
|
940
1027
|
return {
|
|
941
1028
|
ok: true,
|
|
942
|
-
|
|
943
|
-
|
|
1029
|
+
status,
|
|
1030
|
+
stickers: visible.map(({ file, fmt, hash, ...item }) => ({ ...item,
|
|
1031
|
+
...(item.status === 'pending' ? { hash } : {}),
|
|
1032
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(item.name)}&source=${item.source}`
|
|
1033
|
+
+ (item.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(hash)}` : ''),
|
|
944
1034
|
removable: item.source === 'collected',
|
|
945
1035
|
})),
|
|
946
|
-
counts: { total: all.length, builtin: all.filter(item => item.source === 'builtin').length,
|
|
1036
|
+
counts: { total: all.length, builtin: all.filter(item => item.source === 'builtin').length,
|
|
1037
|
+
collected: all.filter(item => item.source === 'collected').length, pending: all.filter(item => item.status === 'pending').length,
|
|
1038
|
+
approved: all.filter(item => item.status === 'approved').length },
|
|
947
1039
|
capacity: cfg.stickerCapacity,
|
|
948
1040
|
settings: stickerSettings(cfg),
|
|
949
1041
|
automation: stickerAutomation.snapshot(),
|
|
@@ -978,19 +1070,35 @@ export function apply(ctx, config) {
|
|
|
978
1070
|
try { goal = exec?.agent ? ctx.get('goals')?.get(exec.agent) : null } catch { /* no goal */ }
|
|
979
1071
|
return personaScope(resolveSessionPreset(ctx, exec?.agent), goal).enabled
|
|
980
1072
|
}
|
|
1073
|
+
const roleTools = createToolScopeRegistry({ ctx, enabled: agent => isFishExecution({ agent }), log })
|
|
1074
|
+
try {
|
|
1075
|
+
onEvent('agent/created', ({ agent }) => roleTools.refresh(agent), '鲸鱼工具会话挂载')
|
|
1076
|
+
onEvent('agent/disposed', ({ agent }) => roleTools.detach(agent), '鲸鱼工具会话卸载')
|
|
1077
|
+
onEvent('agent-preset/selected', (sessionId) => {
|
|
1078
|
+
const agent = (ctx.get('agents')?.list?.() || []).find(item => item.id === sessionId || item.session?.id === sessionId)
|
|
1079
|
+
if (agent) roleTools.refresh(agent)
|
|
1080
|
+
}, '鲸鱼工具预设隔离')
|
|
1081
|
+
ctx.effect(() => () => roleTools.dispose(), '鲸鱼工具作用域清理')
|
|
1082
|
+
} catch (error) { log('鲸鱼工具作用域监听失败:' + String(error?.message ?? error)) }
|
|
981
1083
|
// Content reads and account interactions are evidence of browsing. Settings,
|
|
982
1084
|
// status, memory and bookkeeping tools alone do not turn a chat into a run.
|
|
983
1085
|
const browsingTools = new Set(['bili_search', 'bili_subtitle', 'bili_danmaku', 'bili_buzzwords',
|
|
984
1086
|
'bili_video_info', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_comments',
|
|
985
1087
|
'bili_received_replies', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments',
|
|
986
1088
|
'bili_my_video_comments', 'bili_reply', 'bili_reply_opus', 'bili_dm_inbox',
|
|
987
|
-
'bili_dm_thread', 'bili_dm_reply', 'bili_moment'])
|
|
1089
|
+
'bili_dm_thread', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
988
1090
|
const tool = (spec) => {
|
|
989
1091
|
const wrapped = {
|
|
990
1092
|
...spec,
|
|
991
1093
|
output: textOutput,
|
|
992
1094
|
// ⭐ 流水就记在这一层:18 只工具一个都跑不掉,也不用每只都写一遍 log
|
|
993
1095
|
async execute(args, exec) {
|
|
1096
|
+
const manuallyApproved = exec && approvedReviewExecutions.has(exec)
|
|
1097
|
+
if (!manuallyApproved && !isFishExecution(exec)) return { ok: false, blocked: true, reason: 'mode_required', hint: 'DeepMoYu 工具仅在鲸鱼摸鱼模式内生效;普通会话不会启用角色工具或记忆。' }
|
|
1098
|
+
if (['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'].includes(spec.name)) {
|
|
1099
|
+
const unauthorized = modelForceDecision(args)
|
|
1100
|
+
if (unauthorized) return unauthorized
|
|
1101
|
+
}
|
|
994
1102
|
if (['bili_soul', 'bili_learn', 'bili_taste', 'bili_records', 'bili_sticker'].includes(spec.name)) {
|
|
995
1103
|
const agent = exec?.agent
|
|
996
1104
|
let goal = null
|
|
@@ -1011,8 +1119,10 @@ export function apply(ctx, config) {
|
|
|
1011
1119
|
const id = `t${++actSeq}`
|
|
1012
1120
|
if (!quiet) act('call', describeCall(spec.name, args), { tool: spec.name, id })
|
|
1013
1121
|
try {
|
|
1014
|
-
const v = safe(await toolExecution.run(exec || {}, () => {
|
|
1122
|
+
const v = safe(await toolExecution.run(exec || {}, async () => {
|
|
1015
1123
|
exec?.signal?.throwIfAborted()
|
|
1124
|
+
const review = await queueSendReview(spec.name, args, exec)
|
|
1125
|
+
if (review) return review
|
|
1016
1126
|
return spec.execute(args, exec)
|
|
1017
1127
|
}))
|
|
1018
1128
|
if (!quiet) {
|
|
@@ -1028,7 +1138,7 @@ export function apply(ctx, config) {
|
|
|
1028
1138
|
},
|
|
1029
1139
|
}
|
|
1030
1140
|
backgroundToolRegistry.set(spec.name, wrapped)
|
|
1031
|
-
return
|
|
1141
|
+
return roleTools.register(defineTool(wrapped))
|
|
1032
1142
|
}
|
|
1033
1143
|
|
|
1034
1144
|
tool({
|
|
@@ -1929,8 +2039,8 @@ export function apply(ctx, config) {
|
|
|
1929
2039
|
if (!(isReply ? canReply : canNew) && !args.force) {
|
|
1930
2040
|
return {
|
|
1931
2041
|
ok: false, blocked: true, hint: isReply
|
|
1932
|
-
? '
|
|
1933
|
-
: '
|
|
2042
|
+
? '「自动回复评论」关着(或投递方式是「只发私信」),请本人在工作台调整,模型不能自授权限。'
|
|
2043
|
+
: '「自动评论视频」关着(或投递方式是「只发私信」),请本人在工作台调整,模型不能自授权限。',
|
|
1934
2044
|
}
|
|
1935
2045
|
}
|
|
1936
2046
|
// 内容闸门与 bili_reply 同一套(动态评论同样不能是纯口号)
|
|
@@ -1945,7 +2055,7 @@ export function apply(ctx, config) {
|
|
|
1945
2055
|
return {
|
|
1946
2056
|
ok: false, blocked: true, shallow: true, template: true, rule: t.rule,
|
|
1947
2057
|
reason: t.reason, marks: t.marks,
|
|
1948
|
-
hint: t.hint + '
|
|
2058
|
+
hint: t.hint + '(请改写正文;人工审核也不绕过内容校验。)',
|
|
1949
2059
|
}
|
|
1950
2060
|
}
|
|
1951
2061
|
} catch { /* 模板闸坏了不能挡住发评论 */ }
|
|
@@ -2072,13 +2182,13 @@ export function apply(ctx, config) {
|
|
|
2072
2182
|
+ '10 分钟最多 10 次出手。**没有每日条数上限 —— 想回就回,不想回就放着**。\n'
|
|
2073
2183
|
+ '⚠️ 但私信在 B站 举报权重里最重(是唯一直接推到别人手机的动作):**别对着同一个人连着发**,'
|
|
2074
2184
|
+ '同一个 `msg_seqno` 只回一次(宿主记着账本),回得短、像人说话。\n'
|
|
2075
|
-
+ '🔴
|
|
2185
|
+
+ '🔴 安全闸:「自动回复私信」默认关闭。只有本人在工作台手动打开后才允许发送;模型不能用 force 或配置 PATCH 绕过。\n'
|
|
2076
2186
|
+ '⚠️ 同一个 `msg_seqno` 只回一次(宿主记着账本,重复回会被挡)。**没有每日条数上限** —— 想回就回,不想回就放着。',
|
|
2077
2187
|
parameters: {
|
|
2078
2188
|
uid: { type: 'string', required: true, description: '发给谁(对方 UID)' },
|
|
2079
2189
|
text: { type: 'string', required: true, description: '私信正文(短、像人说话、按人格;别粘链接、别写手机号)' },
|
|
2080
2190
|
msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿;填了才判重,**建议一定要填**)' },
|
|
2081
|
-
force: { type: 'boolean', description: '
|
|
2191
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2082
2192
|
},
|
|
2083
2193
|
async execute(args) {
|
|
2084
2194
|
refreshCfg()
|
|
@@ -2092,7 +2202,7 @@ export function apply(ctx, config) {
|
|
|
2092
2202
|
if (!cfg.autoReplyDm && !args.force) {
|
|
2093
2203
|
return {
|
|
2094
2204
|
ok: false, blocked: true,
|
|
2095
|
-
hint: '
|
|
2205
|
+
hint: '工作台 → 互动与投递中的「自动回复私信」未开启,请本人手动调整;模型不能用 force 自授权限。',
|
|
2096
2206
|
}
|
|
2097
2207
|
}
|
|
2098
2208
|
// 闸门二:判重(同一条私信只回一次 —— 这是最容易被模型搞砸的地方)
|
|
@@ -2119,7 +2229,7 @@ export function apply(ctx, config) {
|
|
|
2119
2229
|
return {
|
|
2120
2230
|
ok: false, blocked: true, perUserCap: true, sentToday: used,
|
|
2121
2231
|
hint: `今天已经回过这个人 ${used} 条了 —— 再连着回就成刷屏了。等他有新消息再接,`
|
|
2122
|
-
+ '
|
|
2232
|
+
+ '或者把话留给还没搭过话的人;模型不能用 force 绕过这道防刷屏检查。',
|
|
2123
2233
|
}
|
|
2124
2234
|
}
|
|
2125
2235
|
/* 闸门四(2026-10-20 补):**按真实的收件箱状态复核一遍**。
|
|
@@ -2155,9 +2265,9 @@ export function apply(ctx, config) {
|
|
|
2155
2265
|
|
|
2156
2266
|
const r = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: seqno })
|
|
2157
2267
|
const o = typeof r === 'object' && r ? r : {}
|
|
2158
|
-
if (o.success === false || o.error) {
|
|
2268
|
+
if (o.success === false || o.error || !(o.success === true || o.ok === true)) {
|
|
2159
2269
|
const risk = paceRiskOf(o)
|
|
2160
|
-
return { ok: false, error: String(o.error || o.message || '
|
|
2270
|
+
return { ok: false, unknown: o.success !== false && !o.error, error: String(o.error || o.message || '发送结果未知'), raw: o, ...(risk ? { riskNote: risk } : {}) }
|
|
2161
2271
|
}
|
|
2162
2272
|
await paceDone('dm')
|
|
2163
2273
|
// 记账本:判重 + 当日额度(**发成功之后才记** —— 失败了记上就再也回不了)
|
|
@@ -2185,6 +2295,44 @@ export function apply(ctx, config) {
|
|
|
2185
2295
|
},
|
|
2186
2296
|
})
|
|
2187
2297
|
|
|
2298
|
+
tool({
|
|
2299
|
+
name: 'bili_deliver',
|
|
2300
|
+
description: '把本轮精选结果合并成一条私信投递给本人已配置的收件 UID。只在私信投递开关开启时可用;不接受任意收件人。最多 500 字,附标题、链接和简短理由,不放私密来信、路径或配置。发送预览开启时先等本人审核,批准仍经过原节奏和判重。',
|
|
2301
|
+
parameters: { text: { type: 'string', required: true, description: '合并后的成果私信,最多 500 字' } },
|
|
2302
|
+
async execute(args) {
|
|
2303
|
+
refreshCfg()
|
|
2304
|
+
const uid = readableId(cfg.dmUid, { allowZero: false })
|
|
2305
|
+
const text = typeof args?.text === 'string' ? args.text.trim() : ''
|
|
2306
|
+
if (!['only', 'both'].includes(cfg.dmMode) || !uid) return { ok: false, blocked: true, error: '私信投递未开启或未配置收件 UID' }
|
|
2307
|
+
if (!text || text.length > 500) return { ok: false, blocked: true, error: '成果私信需要 1–500 字正文' }
|
|
2308
|
+
const approved = approvedReviewArgs.get(args)
|
|
2309
|
+
if (approved && approved.targetUid !== uid) return { ok: false, blocked: true, error: '收件 UID 在预览后已改变,请重新生成预览' }
|
|
2310
|
+
const blocked = paceGate('dm')
|
|
2311
|
+
if (blocked) return blocked
|
|
2312
|
+
const key = createHash('sha256').update(JSON.stringify([dayKey(), uid, text])).digest('hex')
|
|
2313
|
+
const reservation = await withStateLock(() => {
|
|
2314
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
2315
|
+
const book = state.dmDeliveries && typeof state.dmDeliveries === 'object' ? state.dmDeliveries : {}
|
|
2316
|
+
if (book[key]) return { ok: false, blocked: true, duplicate: true, error: '这份结果已投递或正在投递;未知结果不重试', previous: book[key].state }
|
|
2317
|
+
book[key] = { state: 'sending', uid, at: new Date().toISOString() }
|
|
2318
|
+
writeJson(dutyStateFile, { ...state, dmDeliveries: book })
|
|
2319
|
+
return { ok: true }
|
|
2320
|
+
})
|
|
2321
|
+
if (!reservation.ok) return reservation
|
|
2322
|
+
let result
|
|
2323
|
+
try { result = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: '0' }) }
|
|
2324
|
+
catch (error) { result = { ok: false, unknown: true, error: String(error?.message ?? error) } }
|
|
2325
|
+
const success = Boolean(result && !result.error && (result.success === true || result.ok === true))
|
|
2326
|
+
await withStateLock(() => {
|
|
2327
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
2328
|
+
writeJson(dutyStateFile, { ...state, dmDeliveries: { ...(state.dmDeliveries || {}), [key]: { state: success ? 'sent' : 'unknown', uid, at: new Date().toISOString() } } })
|
|
2329
|
+
})
|
|
2330
|
+
if (success) await paceDone('dm')
|
|
2331
|
+
else paceRiskOf(result)
|
|
2332
|
+
return success ? { ok: true, success: true, uid } : { ok: false, unknown: true, error: String(result?.error || '成果私信结果未知,请到 B站核对后再决定') }
|
|
2333
|
+
},
|
|
2334
|
+
})
|
|
2335
|
+
|
|
2188
2336
|
tool({
|
|
2189
2337
|
name: 'bili_comments',
|
|
2190
2338
|
description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。\n'
|
|
@@ -2242,7 +2390,7 @@ export function apply(ctx, config) {
|
|
|
2242
2390
|
+ '把图床链接收进来,起个好叫的名字(如 name:"贴贴");B站默认小表情不用收 —— [doge] 这类码直接写在回复正文里。\n'
|
|
2243
2391
|
+ '⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(60 张)先 forget 再收。\n'
|
|
2244
2392
|
+ 'picture 参数只在 bili_reply **发新评论**时能带(回复楼层不支持图片)。自动采集和自动配图按设置概率实际执行;'
|
|
2245
|
-
+ '你也能主动 collect、补上 description/tags
|
|
2393
|
+
+ '你也能主动 collect、补上 description/tags 并显式选图。开启人工审核时新图先待审,只有使用者在表情收藏页批准后才能用;关闭审核也不会放行旧待审图。自动设置不授予评论权限。',
|
|
2246
2394
|
parameters: {
|
|
2247
2395
|
action: { type: 'string', description: 'list=看库里有什么 · collect=收一张(给 url 或库内 path)· forget=忘掉一张收集区的' },
|
|
2248
2396
|
name: { type: 'string', description: 'collect/forget 用的名字(collect 时起个好叫的,别带路径/扩展名)' },
|
|
@@ -2254,6 +2402,7 @@ export function apply(ctx, config) {
|
|
|
2254
2402
|
rpid: { type: 'string', description: 'collect 来源评论 id(可选,原样字符串)' },
|
|
2255
2403
|
},
|
|
2256
2404
|
async execute(args, exec) {
|
|
2405
|
+
refreshCfg()
|
|
2257
2406
|
const action = String(args?.action ?? 'list').trim() || 'list'
|
|
2258
2407
|
if (action === 'list') {
|
|
2259
2408
|
const all = listStickers(stateDir)
|
|
@@ -2272,15 +2421,18 @@ export function apply(ctx, config) {
|
|
|
2272
2421
|
if (action === 'collect') {
|
|
2273
2422
|
const r = await collectSticker(stateDir, { name: args?.name, url: args?.url, path: args?.path,
|
|
2274
2423
|
description: args?.description, tags: args?.tags, bvid: args?.bvid, rpid: args?.rpid,
|
|
2275
|
-
capacity: cfg.stickerCapacity, signal: exec?.signal
|
|
2424
|
+
capacity: cfg.stickerCapacity, signal: exec?.signal,
|
|
2425
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview } })
|
|
2276
2426
|
if (!r.ok) return { ok: false, error: r.error }
|
|
2277
2427
|
return {
|
|
2278
2428
|
ok: true,
|
|
2279
2429
|
collected: r.name,
|
|
2280
|
-
file: r.file,
|
|
2430
|
+
...(r.status === 'approved' ? { file: r.file } : {}),
|
|
2281
2431
|
bytes: r.bytes,
|
|
2432
|
+
status: r.status,
|
|
2282
2433
|
from: args?.url ? '网络' : '库内拷贝',
|
|
2283
|
-
hint:
|
|
2434
|
+
hint: r.status === 'pending' ? '已放入待审核。请使用者在桌宠设置 → 表情收藏中查看并批准;批准前不能配图。'
|
|
2435
|
+
: '收好了 —— bili_reply({picture:"' + r.name + '"}) 用它。想换图先 forget。',
|
|
2284
2436
|
}
|
|
2285
2437
|
}
|
|
2286
2438
|
if (action === 'forget') {
|
|
@@ -2307,14 +2459,14 @@ export function apply(ctx, config) {
|
|
|
2307
2459
|
+ '说等就等(发新评论 120~180 秒一条、回评论 60~90 秒、每小时各有上限、10 分钟最多 10 次出手)。'
|
|
2308
2460
|
+ '被拦是正常的,换个活干、过会儿再来,别试探性重发(那是 -352 的最短路径)。\n'
|
|
2309
2461
|
+ '🔴 安全闸:桌宠设置「自动化」里的「自动评论视频」**默认是关的**;关着的时候这个工具会拒绝 —— '
|
|
2310
|
-
+ '
|
|
2462
|
+
+ '需要本人在工作台手动打开权限;模型不能用 force 或配置 PATCH 绕过。',
|
|
2311
2463
|
parameters: {
|
|
2312
2464
|
bvid: { type: 'string', description: 'BV号' },
|
|
2313
2465
|
text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
|
|
2314
2466
|
rpid: { type: 'string', description: '要回复的评论 id(0/不填 = 发新评论)' },
|
|
2315
2467
|
root: { type: 'string', description: '你那条主评论的 id(回复楼中楼时填;回复一级评论不用填)' },
|
|
2316
2468
|
picture: { type: 'string', description: '(可选,仅发新评论)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查;回复楼层不支持图片' },
|
|
2317
|
-
force: { type: 'boolean', description: '
|
|
2469
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2318
2470
|
},
|
|
2319
2471
|
async execute(args) {
|
|
2320
2472
|
const bvid = String(args.bvid ?? '').trim()
|
|
@@ -2335,7 +2487,9 @@ export function apply(ctx, config) {
|
|
|
2335
2487
|
const fromUrl = (bvid.match(/BV[0-9A-Za-z]{10}/) || [])[0]
|
|
2336
2488
|
if (fromUrl && !looksOpus) {
|
|
2337
2489
|
// 落到下面正常流程(把 bvid 换成抠出来的那个)
|
|
2490
|
+
const reviewed = approvedReviewArgs.get(args)
|
|
2338
2491
|
args = { ...args, bvid: fromUrl }
|
|
2492
|
+
if (reviewed) approvedReviewArgs.set(args, reviewed)
|
|
2339
2493
|
} else {
|
|
2340
2494
|
return {
|
|
2341
2495
|
ok: false,
|
|
@@ -2368,10 +2522,10 @@ export function apply(ctx, config) {
|
|
|
2368
2522
|
ok: false,
|
|
2369
2523
|
blocked: true,
|
|
2370
2524
|
hint: !publicOk
|
|
2371
|
-
? '现在是「只私信」模式(dmMode=only
|
|
2525
|
+
? '现在是「只私信」模式(dmMode=only),不能发公开评论。需要本人在工作台调整投递方式。'
|
|
2372
2526
|
: isReply
|
|
2373
|
-
? '
|
|
2374
|
-
: '
|
|
2527
|
+
? '工作台 → 互动与投递中的「自动回复评论」未开启,请本人手动调整。'
|
|
2528
|
+
: '工作台 → 互动与投递中的「自动评论视频」未开启,请本人手动调整。',
|
|
2375
2529
|
}
|
|
2376
2530
|
}
|
|
2377
2531
|
// 2026-10-20:回复也走判重 —— 回过的别再回(上限取消了,判重是唯一刹车)
|
|
@@ -2428,7 +2582,7 @@ export function apply(ctx, config) {
|
|
|
2428
2582
|
return {
|
|
2429
2583
|
ok: false, blocked: true, shallow: true, template: true, rule: t.rule,
|
|
2430
2584
|
reason: t.reason, marks: t.marks,
|
|
2431
|
-
hint: t.hint + '
|
|
2585
|
+
hint: t.hint + '(最近几条评论可在口味本子里查看;请改写正文,不能用 force 绕过质量检查。)',
|
|
2432
2586
|
}
|
|
2433
2587
|
}
|
|
2434
2588
|
} catch { /* 模板闸自己坏了不能挡住发评论 —— 上面三道质量闸已经过了 */ }
|
|
@@ -2442,6 +2596,7 @@ export function apply(ctx, config) {
|
|
|
2442
2596
|
* · 解析失败**不拦发送**是不对的——图没了话还在,但模型想要的就是那张图,
|
|
2443
2597
|
* 所以这里明确拒绝并说明原因,让它决定是去掉图重发还是先去 collect。 */
|
|
2444
2598
|
let pictures
|
|
2599
|
+
let pictureSnapshots = []
|
|
2445
2600
|
const picInput = String(args.picture ?? '').trim()
|
|
2446
2601
|
if (picInput) {
|
|
2447
2602
|
if (isReply) {
|
|
@@ -2460,31 +2615,54 @@ export function apply(ctx, config) {
|
|
|
2460
2615
|
hint: `一条评论最多带 2 张图(拿到 ${names.length} 个名字)—— 挑最重要的两张,其余留给下一条。`,
|
|
2461
2616
|
}
|
|
2462
2617
|
}
|
|
2463
|
-
const files = []
|
|
2618
|
+
const files = [], snapshots = []
|
|
2464
2619
|
for (const nm of names) {
|
|
2465
|
-
|
|
2466
|
-
|
|
2620
|
+
// Read approved bytes under the library lock and hand the sender a private
|
|
2621
|
+
// snapshot. A forget + same-name pending upload then cannot swap the file
|
|
2622
|
+
// while MCP is still doing its own IO.
|
|
2623
|
+
const shot = snapshotStickerForSend(stateDir, nm)
|
|
2624
|
+
if (!shot) {
|
|
2625
|
+
for (const made of snapshots) made.cleanup()
|
|
2467
2626
|
return {
|
|
2468
2627
|
ok: false, blocked: true, sticker_unknown: true,
|
|
2469
|
-
hint: `表情「${nm.slice(0, 40)}
|
|
2628
|
+
hint: `表情「${nm.slice(0, 40)}」不在已审核通过的库里 —— 用 bili_sticker({action:"list"}) 看现有的,`
|
|
2470
2629
|
+ '或 bili_sticker({action:"collect", url:"…", name:"…"}) 先收进来。本条没发(怕你想要的就是那张图)。',
|
|
2471
2630
|
}
|
|
2472
2631
|
}
|
|
2473
|
-
|
|
2632
|
+
const approved = approvedReviewArgs.get(args)
|
|
2633
|
+
if (approved) {
|
|
2634
|
+
const expected = (approved.attachments || []).find(item => item.name === shot.name)
|
|
2635
|
+
const hash = createHash('sha256').update(readFileSync(shot.file)).digest('hex')
|
|
2636
|
+
if (!expected || expected.hash !== hash) {
|
|
2637
|
+
shot.cleanup()
|
|
2638
|
+
for (const made of snapshots) made.cleanup()
|
|
2639
|
+
return { ok: false, blocked: true, error: '预览后的表情字节已变化,请拒绝旧预览后重新生成', attachmentChanged: true }
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
snapshots.push(shot)
|
|
2643
|
+
files.push(shot.file)
|
|
2474
2644
|
}
|
|
2475
2645
|
pictures = files
|
|
2646
|
+
pictureSnapshots = snapshots
|
|
2476
2647
|
}
|
|
2477
2648
|
|
|
2478
2649
|
/* ⏱️ 出手节奏(2026-10-21 m18802 第 1 条,使用者原话「短时间内连点,几分钟发十几条,
|
|
2479
2650
|
* 系统就可能会判为机器」):发新评论和回别人**分开算**(两种间隔不一样),
|
|
2480
2651
|
* 但连发刹车是**合着算**的 —— 风控看的是"这个号在动",不看动的是哪种。 */
|
|
2481
2652
|
const paceBlocked = paceGate(isReply ? 'reply' : 'comment', Boolean(args.force))
|
|
2482
|
-
if (paceBlocked)
|
|
2653
|
+
if (paceBlocked) {
|
|
2654
|
+
for (const made of pictureSnapshots) made.cleanup()
|
|
2655
|
+
return paceBlocked
|
|
2656
|
+
}
|
|
2483
2657
|
|
|
2484
2658
|
// Only a missing field delegates selection. Explicit picture:"" still means text-only.
|
|
2485
|
-
|
|
2659
|
+
let autoPicture = !isReply && !Object.hasOwn(args, 'picture') && isFishExecution(toolExecution.getStore())
|
|
2486
2660
|
? stickerAutomation.pickForSend(String(args.text ?? '')) : null
|
|
2487
|
-
if (autoPicture)
|
|
2661
|
+
if (autoPicture) {
|
|
2662
|
+
const shot = snapshotStickerForSend(stateDir, autoPicture.name)
|
|
2663
|
+
if (shot) { pictures = [shot.file]; pictureSnapshots = [shot] }
|
|
2664
|
+
else { autoPicture.finish(false); autoPicture = null }
|
|
2665
|
+
}
|
|
2488
2666
|
let out
|
|
2489
2667
|
try {
|
|
2490
2668
|
out = await call('bili_reply', {
|
|
@@ -2494,7 +2672,11 @@ export function apply(ctx, config) {
|
|
|
2494
2672
|
root,
|
|
2495
2673
|
...(pictures ? { pictures } : {}),
|
|
2496
2674
|
})
|
|
2497
|
-
} finally {
|
|
2675
|
+
} finally {
|
|
2676
|
+
autoPicture?.finish(sendOk(out))
|
|
2677
|
+
for (const made of pictureSnapshots) made.cleanup()
|
|
2678
|
+
pictureSnapshots = []
|
|
2679
|
+
}
|
|
2498
2680
|
if (autoPicture && out && typeof out === 'object') out = { ...out, auto_picture: autoPicture.name }
|
|
2499
2681
|
// 发成功才记节奏(失败也记会把后面本来能发的推后)
|
|
2500
2682
|
if (sendOk(out)) await paceDone(isReply ? 'reply' : 'comment')
|
|
@@ -2595,7 +2777,7 @@ export function apply(ctx, config) {
|
|
|
2595
2777
|
tokensTotal: Number(st.tokensTotal) || 0,
|
|
2596
2778
|
totalTokenBudget: Number(cfg.totalTokenBudget) || 0,
|
|
2597
2779
|
dmMode: cfg.dmMode,
|
|
2598
|
-
dmUid:
|
|
2780
|
+
dmUid: cfg.dmUid,
|
|
2599
2781
|
transcribe: cfg.transcribe,
|
|
2600
2782
|
loggedIn: hasCredential(),
|
|
2601
2783
|
home: stateDir,
|
|
@@ -2687,7 +2869,7 @@ export function apply(ctx, config) {
|
|
|
2687
2869
|
+ '适合"我现在就想看一份"的场合。\n'
|
|
2688
2870
|
+ '⚠️ 这是**老的后台刷法**(另起进程、当前对话里看不见)。要"在当前对话里一直刷"用 `bili_feiyu`。',
|
|
2689
2871
|
parameters: {},
|
|
2690
|
-
async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
|
|
2872
|
+
async execute() { const r = runDuty('manual'); return { ok: r.started === true, ...r } },
|
|
2691
2873
|
})
|
|
2692
2874
|
|
|
2693
2875
|
/* ⭐ 鲸鱼摸鱼模式:对话启动 ⇒ 用当前对话模型一直刷(宿主 goal 机制自动续轮) */
|
|
@@ -2716,51 +2898,42 @@ export function apply(ctx, config) {
|
|
|
2716
2898
|
},
|
|
2717
2899
|
})
|
|
2718
2900
|
|
|
2719
|
-
|
|
2720
|
-
* 「每天生成一个视频讲今天的冲浪情况,用鲸鱼摸鱼的虚拟形象讲解,要有声音」。
|
|
2721
|
-
* 它**不发布**(使用者先说本地看),所以这只工具只负责做出来 + 告诉你在哪。 */
|
|
2901
|
+
// The selected conversation model authors the whole film; the renderer executes its validated plan.
|
|
2722
2902
|
tool({
|
|
2723
2903
|
name: 'bili_pv',
|
|
2724
|
-
description: '🎬
|
|
2725
|
-
+ '
|
|
2726
|
-
+ '
|
|
2727
|
-
+ '
|
|
2728
|
-
+ ' 进度用 `status` 看,或看桌宠 ⚙️ 设置 →「视频 · 模型」。日期不填 = 昨天。',
|
|
2904
|
+
description: '🎬 视频工作室:日报 / 观点放大镜 / 精选安利 / 自由创作,成片保存在本地。\n'
|
|
2905
|
+
+ 'make(默认)新开可见制作会话,model 指定负责整条视频的模型;不是只选台词写手。\n'
|
|
2906
|
+
+ '制作会话先 prepare 取约定、分镜规范和当天公开素材,再由当前模型提交 preview 检查节奏,确认后提交 render 的完整 production JSON。\n'
|
|
2907
|
+
+ '渲染异步执行,status 查进度与失败日志;只有 done 且验证通过才交付。list 看成片与制作会话,stop 停本会话的渲染。',
|
|
2729
2908
|
parameters: {
|
|
2730
|
-
action: { type: 'string', enum: ['make', 'status', 'list', 'stop'], description: 'make
|
|
2731
|
-
date: { type: 'string', description: '
|
|
2732
|
-
|
|
2733
|
-
|
|
2909
|
+
action: { type: 'string', enum: ['make', 'session', 'prepare', 'preview', 'render', 'status', 'list', 'stop'], description: 'make/session=新开制作会话 · prepare=取规范和素材 · preview=检查分镜节奏 · render=提交最终计划 · status/list/stop' },
|
|
2910
|
+
date: { type: 'string', description: '素材日期 YYYY-MM-DD,默认本地昨天' },
|
|
2911
|
+
mode: { type: 'string', enum: ['daily', 'rant', 'highlights', 'custom'], description: '冲浪日报 / 观点放大镜 / 精选安利 / 自由创作' },
|
|
2912
|
+
brief: { type: 'string', description: '本条视频的创作要求,最多2000字' },
|
|
2913
|
+
targetDurationSec: { type: 'number', description: '目标时长15–300秒,默认90秒' },
|
|
2914
|
+
model: { type: 'string', description: '负责整条视频的 provider/model;省略用已保存选择或来源会话模型' },
|
|
2915
|
+
production: { type: 'string', description: 'render 必填:符合 prepare 规范的完整 JSON 对象字符串,不含 Markdown 围栏' },
|
|
2734
2916
|
},
|
|
2735
|
-
async execute(args) {
|
|
2917
|
+
async execute(args, exec) {
|
|
2736
2918
|
refreshCfg()
|
|
2919
|
+
const sessionId = String(exec?.agent?.id || '')
|
|
2737
2920
|
const action = String(args?.action || 'make').toLowerCase()
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
}
|
|
2747
|
-
if (action === 'stop') return pv.stop()
|
|
2748
|
-
// make
|
|
2749
|
-
const day = String(args?.date || '').trim() || pvYesterday()
|
|
2750
|
-
if (!pvDateOk(day)) return { ok: false, error: `日期得是 YYYY-MM-DD(拿到的是 "${day}")` }
|
|
2751
|
-
const dep = await pv.checkDeps()
|
|
2752
|
-
if (!dep) {
|
|
2753
|
-
return {
|
|
2754
|
-
ok: false,
|
|
2755
|
-
error: '渲染依赖没装齐(缺 edge-tts / imageio-ffmpeg / Pillow 之一)—— 桌宠 ⚙️ 设置 →「账号」里点一次「装依赖」就好',
|
|
2756
|
-
python: pvPython(),
|
|
2921
|
+
try {
|
|
2922
|
+
if (action === 'prepare') return videoStudio.prepare(args, sessionId)
|
|
2923
|
+
if (action === 'preview') return videoStudio.preview(args, sessionId)
|
|
2924
|
+
if (action === 'render') return await videoStudio.render(args, sessionId)
|
|
2925
|
+
if (action === 'status') return { ...videoStudio.status(sessionId), modelId: videoStudio.sessionRow(sessionId)?.model || cfg.videoModelId || '' }
|
|
2926
|
+
if (action === 'list') {
|
|
2927
|
+
const videos = pv.list(), productions = pv.productions()
|
|
2928
|
+
return { ok: true, out: pvDirsOf().out, count: videos.length + productions.length, videos, productions, sessions: videoStudio.list() }
|
|
2757
2929
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2930
|
+
if (action === 'stop') return videoStudio.stop(sessionId)
|
|
2931
|
+
if (action === 'make' || action === 'session') {
|
|
2932
|
+
if (videoStudio.sessionRow(sessionId)) return { ...videoStudio.prepare(args, sessionId), hint: '已在制作会话中;请编排完整计划后调用 render,无需重复创建会话。' }
|
|
2933
|
+
return await videoStudio.start({ ...args, ...(sessionId ? { sourceSessionId: sessionId } : {}) })
|
|
2934
|
+
}
|
|
2935
|
+
return { ok: false, error: `不认识的 action:${action}` }
|
|
2936
|
+
} catch (error) { return { ok: false, error: String(error?.message || error) } }
|
|
2764
2937
|
},
|
|
2765
2938
|
})
|
|
2766
2939
|
|
|
@@ -2768,12 +2941,16 @@ export function apply(ctx, config) {
|
|
|
2768
2941
|
tool({
|
|
2769
2942
|
name: 'bili_moment',
|
|
2770
2943
|
description: '🐟 发一条 **B站动态**(就是"说说"):`text` 是正文,可带图。\n'
|
|
2771
|
-
+ '⚠️
|
|
2772
|
-
+ '一天最多 `momentPerDay` 条(默认 1);正文 300
|
|
2944
|
+
+ '⚠️ 这是拿使用者账号公开发言:只有本人在工作台手动打开 autoMoment 后才能发送;force 不是本人授权。\n'
|
|
2945
|
+
+ '一天最多 `momentPerDay` 条(默认 1);正文 300 字以内,别写风控词、别贴链接。\n'
|
|
2946
|
+
+ '✍️ **动态是自己在开口,不是日报的缩写**:一条只装一个念头,别把今天看了几条、挑中哪条列一遍;'
|
|
2947
|
+
+ '别让「今天看了/读了/挑了/学到…」当默认入口,连着几条同一个起头读的人先看见句式。'
|
|
2948
|
+
+ '换个门进来(一句吐槽、一个还没想通的问题、一个甩不掉的画面、跟视频无关的联想都行),'
|
|
2949
|
+
+ '想提视频就一句带过,长度跟着念头走。**没有想说的就不发,配额不是催办。**',
|
|
2773
2950
|
parameters: {
|
|
2774
2951
|
text: { type: 'string', description: '正文(300 字以内,纯文本)' },
|
|
2775
2952
|
images: { type: 'array', items: { type: 'string' }, description: '(可选)图片地址或本地路径,最多 9 张' },
|
|
2776
|
-
force: { type: 'boolean', description: '
|
|
2953
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2777
2954
|
},
|
|
2778
2955
|
async execute(args) {
|
|
2779
2956
|
refreshCfg()
|
|
@@ -2784,7 +2961,7 @@ export function apply(ctx, config) {
|
|
|
2784
2961
|
if (!gateOn && args?.force !== true) {
|
|
2785
2962
|
return {
|
|
2786
2963
|
ok: false, blocked: true,
|
|
2787
|
-
hint: '
|
|
2964
|
+
hint: '工作台中的「自动发说说」未开启,请本人手动打开。模型不能通过 force 或配置 PATCH 自授权限。',
|
|
2788
2965
|
}
|
|
2789
2966
|
}
|
|
2790
2967
|
// ⚠️ 别用 `Number(...) || 1`:`momentPerDay: 0` 会被吞成 1,而设置页写的是「0 = 不发」。
|
|
@@ -2792,7 +2969,7 @@ export function apply(ctx, config) {
|
|
|
2792
2969
|
const cap = Number.isFinite(capNum) ? Math.max(0, capNum) : 1
|
|
2793
2970
|
const used = countMomentsToday()
|
|
2794
2971
|
if (cap <= 0 && args?.force !== true) {
|
|
2795
|
-
return { ok: false, blocked: true, hint: '
|
|
2972
|
+
return { ok: false, blocked: true, hint: '工作台中的动态每日上限是 0(不发),请本人手动调整;模型不能绕过额度。' }
|
|
2796
2973
|
}
|
|
2797
2974
|
if (used >= cap && args?.force !== true) {
|
|
2798
2975
|
return { ok: false, blocked: true, hint: `今天已经发了 ${used}/${cap} 条说说了,明天再聊。` }
|
|
@@ -2821,7 +2998,8 @@ export function apply(ctx, config) {
|
|
|
2821
2998
|
try { r = await call('bili_send_dynamic', { text, images: Array.isArray(args?.images) ? args.images : [] }) }
|
|
2822
2999
|
catch (error) {
|
|
2823
3000
|
// Cancellation before submission did not spend this reservation.
|
|
2824
|
-
|
|
3001
|
+
const reviewed = approvedReviewExecutions.get(toolExecution.getStore())
|
|
3002
|
+
if (reserved > 0 && ((toolExecution.getStore()?.signal?.aborted && error?.name === 'AbortError') || reviewed && !reviewed.submitted)) await bumpMomentBy(-1)
|
|
2825
3003
|
throw error
|
|
2826
3004
|
}
|
|
2827
3005
|
let ok = false
|
|
@@ -2836,6 +3014,8 @@ export function apply(ctx, config) {
|
|
|
2836
3014
|
}
|
|
2837
3015
|
await paceDone('moment')
|
|
2838
3016
|
const n = reserved > 0 ? reserved : await bumpMoment()
|
|
3017
|
+
// 🪞 记下这次的开口(失败的不记)—— 下一轮实时信息里会把它摆给模型看
|
|
3018
|
+
await rememberMomentOpening(text)
|
|
2839
3019
|
log(`🐟 发了一条说说(今天 ${n}/${cap}):${text.slice(0, 40)}`)
|
|
2840
3020
|
return { ok: true, momentsToday: n, cap, text }
|
|
2841
3021
|
},
|
|
@@ -2848,8 +3028,9 @@ export function apply(ctx, config) {
|
|
|
2848
3028
|
+ '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
|
|
2849
3029
|
parameters: {},
|
|
2850
3030
|
async execute() {
|
|
2851
|
-
const { key, url } =
|
|
3031
|
+
const attempt = await loginSession.start(), { key, url } = attempt
|
|
2852
3032
|
const png = await renderQrPng(cfg.pythonPath, stateDir, url)
|
|
3033
|
+
if (!loginSession.current(attempt)) return { ok: false, error: '登录请求已被新的二维码替代,请重新查看最新二维码' }
|
|
2853
3034
|
return {
|
|
2854
3035
|
ok: true, key, url, png,
|
|
2855
3036
|
hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去桌宠 ⚙️ 设置 →「账号」里点「装主依赖」(venv 里要有 qrcode)',
|
|
@@ -2863,9 +3044,9 @@ export function apply(ctx, config) {
|
|
|
2863
3044
|
+ '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
|
|
2864
3045
|
parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
|
|
2865
3046
|
async execute(args) {
|
|
2866
|
-
const r = await
|
|
3047
|
+
const r = await loginSession.poll(String(args.key ?? ''))
|
|
2867
3048
|
if (r.state === 'ok') {
|
|
2868
|
-
const file =
|
|
3049
|
+
const file = r.file
|
|
2869
3050
|
log(`登录成功,凭证已写入 ${file}`)
|
|
2870
3051
|
return { ok: true, state: 'ok', file }
|
|
2871
3052
|
}
|
|
@@ -2883,13 +3064,14 @@ export function apply(ctx, config) {
|
|
|
2883
3064
|
|
|
2884
3065
|
tool({
|
|
2885
3066
|
name: 'bili_config',
|
|
2886
|
-
description: '
|
|
2887
|
-
+ '
|
|
2888
|
-
+ '私信投递:`{"dmMode":"only","dmUid":12345678}`(dmMode = off / only / both)。',
|
|
3067
|
+
description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 persona/focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好。\n'
|
|
3068
|
+
+ '例如:`{"perDay":5,"reportLevel":"brief","keywords":["DeepSeek Harness"]}`。发送权限、收件人、审核、防护、预算、自动任务和执行路径只能由本人在工作台手动设置;模型 PATCH 不能授予权限。',
|
|
2889
3069
|
parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
|
|
2890
3070
|
async execute(args) {
|
|
2891
3071
|
let host = null
|
|
2892
3072
|
if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
|
|
3073
|
+
const decision = modelConfigDecision(args.patch)
|
|
3074
|
+
if (!decision.ok) return decision
|
|
2893
3075
|
// ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
|
|
2894
3076
|
host = (await persistCfg(args.patch)).host
|
|
2895
3077
|
}
|
|
@@ -2913,6 +3095,188 @@ export function apply(ctx, config) {
|
|
|
2913
3095
|
return run
|
|
2914
3096
|
}
|
|
2915
3097
|
|
|
3098
|
+
/* ── ✋ 发送前预览队列 ───────────────────────────────────────────────────────
|
|
3099
|
+
* `sendReview` 只对鱼模式的自动发送生效。默认关闭,旧发送语义不变。
|
|
3100
|
+
* 队列落在 state.json,重启后仍能看到待处理内容;批准时重新走原工具,
|
|
3101
|
+
* 进程内 WeakMap 只通过预览排队这一层,原开关、质量、判重、节奏和风控闸都不跳过。 */
|
|
3102
|
+
const SEND_REVIEW_TOOLS = new Set(['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
3103
|
+
const SEND_REVIEW_MAX = 80
|
|
3104
|
+
const approvedReviewArgs = new WeakMap()
|
|
3105
|
+
const approvedReviewExecutions = new WeakMap()
|
|
3106
|
+
const reviewSending = new Set()
|
|
3107
|
+
const reviewKind = (toolName, args = {}) => {
|
|
3108
|
+
if (toolName === 'bili_dm_reply') return '私信'
|
|
3109
|
+
if (toolName === 'bili_deliver') return '成果私信'
|
|
3110
|
+
if (toolName === 'bili_moment') return '动态'
|
|
3111
|
+
if (toolName === 'bili_reply_opus') return String(args.rpid || '0') === '0' ? '动态评论' : '动态回复'
|
|
3112
|
+
return String(args.rpid || '0') === '0' ? '视频评论' : '视频回复'
|
|
3113
|
+
}
|
|
3114
|
+
const reviewArgs = (args = {}) => {
|
|
3115
|
+
const out = {}
|
|
3116
|
+
for (const key of Object.keys(args || {}).sort()) {
|
|
3117
|
+
const value = args[key]
|
|
3118
|
+
if (key === 'force' || key.startsWith('_')) continue
|
|
3119
|
+
if (typeof value === 'string') out[key] = value
|
|
3120
|
+
else if (Array.isArray(value)) out[key] = value
|
|
3121
|
+
else if (value !== undefined) out[key] = value
|
|
3122
|
+
}
|
|
3123
|
+
return out
|
|
3124
|
+
}
|
|
3125
|
+
const reviewIdOf = (toolName, args, at = Date.now()) => 'sr-' + createHash('sha256').update(JSON.stringify([toolName, reviewArgs(args), at, process.pid])).digest('hex').slice(0, 20)
|
|
3126
|
+
const reviewHash = row => createHash('sha256').update(JSON.stringify([row.tool, reviewArgs(row.args), row.attachments || [], row.targetUid || ''])).digest('hex')
|
|
3127
|
+
const readReviewState = () => {
|
|
3128
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
3129
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) || state.sendReview !== undefined && !Array.isArray(state.sendReview)) throw new Error('发送预览记录损坏,已停止操作以保留原件')
|
|
3130
|
+
return state
|
|
3131
|
+
}
|
|
3132
|
+
const reviewBook = () => {
|
|
3133
|
+
const state = readReviewState()
|
|
3134
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3135
|
+
return rows.filter((row) => row && typeof row === 'object' && row.id && row.args && row.tool)
|
|
3136
|
+
.map(row => row.state === 'approving' && !reviewSending.has(row.id) ? { ...row, state: 'unknown', error: '审批期间运行中断,发送结果未知。请去 B站核对,不能直接重试。' } : row)
|
|
3137
|
+
}
|
|
3138
|
+
const reviewSnapshot = (withItems = true) => {
|
|
3139
|
+
const rows = reviewBook()
|
|
3140
|
+
return {
|
|
3141
|
+
ok: true,
|
|
3142
|
+
enabled: Boolean(cfg.sendReview),
|
|
3143
|
+
rev: createHash('sha256').update(JSON.stringify([Boolean(cfg.sendReview), rows.map(row => [row.id, row.state, row.hash, row.updatedAt])])).digest('hex'),
|
|
3144
|
+
pending: rows.filter((row) => row.state === 'pending').length,
|
|
3145
|
+
sending: rows.filter((row) => row.state === 'approving').length,
|
|
3146
|
+
unknown: rows.filter((row) => row.state === 'unknown').length,
|
|
3147
|
+
counts: Object.fromEntries(['pending', 'approving', 'unknown', 'sent', 'rejected', 'failed'].map(key => [key, rows.filter(row => row.state === key).length])),
|
|
3148
|
+
...(withItems ? { items: rows.slice(-SEND_REVIEW_MAX).reverse().map((row) => ({ ...row, args: reviewArgs(row.args) })) } : {}),
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
const queueSendReview = async (toolName, args, exec) => {
|
|
3152
|
+
if (!SEND_REVIEW_TOOLS.has(toolName)) return null
|
|
3153
|
+
refreshCfg()
|
|
3154
|
+
if (!cfg.sendReview || approvedReviewArgs.has(args) || !isFishExecution(exec)) return null
|
|
3155
|
+
const text = typeof args?.text === 'string' ? args.text.trim() : ''
|
|
3156
|
+
if (!text || text.length > (toolName === 'bili_moment' ? 300 : toolName === 'bili_dm_reply' ? 200 : toolName === 'bili_deliver' ? 500 : 1000)) return { ok: false, blocked: true, error: '发送预览需要有效且未超长的正文' }
|
|
3157
|
+
let clean
|
|
3158
|
+
try { clean = validateToolArguments(toolInputSchema(backgroundToolRegistry.get(toolName)?.parameters || {}), reviewArgs(args)) }
|
|
3159
|
+
catch (error) { return { ok: false, blocked: true, error: String(error?.message ?? error) } }
|
|
3160
|
+
if (JSON.stringify(clean).length > 16000) return { ok: false, blocked: true, error: '预览参数过长,未保存内容' }
|
|
3161
|
+
if (toolName === 'bili_reply' && !Object.hasOwn(clean, 'picture')) clean.picture = ''
|
|
3162
|
+
const now = Date.now()
|
|
3163
|
+
const id = reviewIdOf(toolName, clean, now)
|
|
3164
|
+
const attachments = []
|
|
3165
|
+
if (toolName === 'bili_reply' && clean.picture) {
|
|
3166
|
+
const names = String(clean.picture).split(/[,,、]+|\s+/).filter(Boolean)
|
|
3167
|
+
if (names.length > 2) return { ok: false, blocked: true, error: '发送预览最多选择 2 张表情' }
|
|
3168
|
+
for (const name of names) {
|
|
3169
|
+
const shot = snapshotStickerForSend(stateDir, name)
|
|
3170
|
+
if (!shot) return { ok: false, blocked: true, error: '图片未审核或已变化,请重新选择:' + name }
|
|
3171
|
+
try {
|
|
3172
|
+
const hash = createHash('sha256').update(readFileSync(shot.file)).digest('hex')
|
|
3173
|
+
attachments.push({ name: shot.name, hash, imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(shot.name)}&source=${shot.source}&hash=${hash}` })
|
|
3174
|
+
}
|
|
3175
|
+
finally { shot.cleanup() }
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
if (toolName === 'bili_moment' && clean.images?.length) {
|
|
3179
|
+
if (clean.images.length > 9) return { ok: false, blocked: true, error: '动态最多 9 张附图' }
|
|
3180
|
+
for (const name of clean.images) {
|
|
3181
|
+
const shot = snapshotStickerForSend(stateDir, name)
|
|
3182
|
+
if (!shot) return { ok: false, blocked: true, error: '预览动态附图需先收进已审核表情库;不预览随时可能变化的远程 URL:' + name }
|
|
3183
|
+
try {
|
|
3184
|
+
const bytes = readFileSync(shot.file), hash = createHash('sha256').update(bytes).digest('hex')
|
|
3185
|
+
const dir = join(stateDir, 'send-review-media')
|
|
3186
|
+
mkdirSync(dir, { recursive: true })
|
|
3187
|
+
const fmt = sniffImageFormat(bytes)
|
|
3188
|
+
const file = join(dir, hash + '.' + fmt)
|
|
3189
|
+
try { writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 }) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
3190
|
+
attachments.push({ name: shot.name, hash, kind: 'dynamic', fmt, mime: ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' })[fmt] || 'application/octet-stream', imageUrl: `/api/bili-duty/send-review/image?hash=${hash}` })
|
|
3191
|
+
} finally { shot.cleanup() }
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
const row = { id, tool: toolName, kind: reviewKind(toolName, clean), args: clean, attachments, targetUid: toolName === 'bili_deliver' ? readableId(cfg.dmUid) : '', state: 'pending', createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(), sourceSessionId: String(exec?.agent?.id || '') }
|
|
3195
|
+
row.hash = reviewHash(row)
|
|
3196
|
+
const result = await withStateLock(() => {
|
|
3197
|
+
const state = readReviewState()
|
|
3198
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : []
|
|
3199
|
+
exec?.signal?.throwIfAborted()
|
|
3200
|
+
const duplicate = rows.find((item) => ['pending', 'approving', 'unknown', 'sent'].includes(item.state) && item.hash === row.hash)
|
|
3201
|
+
if (duplicate) return { row: duplicate, duplicate: true }
|
|
3202
|
+
if (rows.filter(item => ['pending', 'approving', 'unknown'].includes(item.state)).length >= SEND_REVIEW_MAX) return { error: '待审队列已满,请先处理现有预览' }
|
|
3203
|
+
rows.push(row)
|
|
3204
|
+
while (rows.length > SEND_REVIEW_MAX) {
|
|
3205
|
+
const removable = rows.findIndex(item => ['sent', 'rejected', 'failed'].includes(item.state))
|
|
3206
|
+
if (removable < 0) break
|
|
3207
|
+
rows.splice(removable, 1)
|
|
3208
|
+
}
|
|
3209
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3210
|
+
return { row, duplicate: false }
|
|
3211
|
+
})
|
|
3212
|
+
if (result?.error) return { ok: false, blocked: true, error: result.error }
|
|
3213
|
+
const saved = result?.row || row
|
|
3214
|
+
act('note', `${result?.duplicate ? '这条' : '新'}${saved.kind}已放进发送预览,等你点头`, { reviewId: saved.id, tool: toolName })
|
|
3215
|
+
return { ok: true, queued: true, blocked: true, reviewId: saved.id, duplicate: Boolean(result?.duplicate), kind: saved.kind,
|
|
3216
|
+
hint: '发送预览已打开:内容还没发出去,去桌宠「互动与投递」的待审核区批准或拒绝。' }
|
|
3217
|
+
}
|
|
3218
|
+
const decideSendReview = async (id, decision, expectedHash) => {
|
|
3219
|
+
const key = String(id || '').trim()
|
|
3220
|
+
if (!/^sr-[a-f0-9]{20}$/.test(key)) return { ok: false, error: 'reviewId 无效' }
|
|
3221
|
+
if (!['approve', 'reject'].includes(decision)) return { ok: false, error: 'decision 只能是 approve / reject' }
|
|
3222
|
+
if (!/^[a-f0-9]{64}$/.test(String(expectedHash || ''))) return { ok: false, error: '请重新查看预览后提交 expectedHash' }
|
|
3223
|
+
let target = null
|
|
3224
|
+
const claimed = await withStateLock(() => {
|
|
3225
|
+
const state = readReviewState()
|
|
3226
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3227
|
+
const index = rows.findIndex((item) => item?.id === key)
|
|
3228
|
+
if (index < 0) return { ok: false, error: '找不到这条待审核内容' }
|
|
3229
|
+
const row = rows[index]
|
|
3230
|
+
if (row.hash !== expectedHash || reviewHash(row) !== expectedHash) return { ok: false, error: '预览内容已变化,请重新查看后决定' }
|
|
3231
|
+
if (row.state !== 'pending') return { ok: false, already: true, item: row }
|
|
3232
|
+
if (decision === 'approve' && (!Number.isFinite(Date.parse(row.createdAt)) || Date.now() - Date.parse(row.createdAt) > 24 * 3600_000)) return { ok: false, error: '这条预览已超过 24 小时,请拒绝旧内容并重新生成,避免发送过时回复' }
|
|
3233
|
+
target = { ...row, args: reviewArgs(row.args) }
|
|
3234
|
+
rows[index] = { ...row, state: decision === 'approve' ? 'approving' : 'rejected', updatedAt: new Date().toISOString(), decidedAt: new Date().toISOString() }
|
|
3235
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3236
|
+
if (decision === 'approve') reviewSending.add(key)
|
|
3237
|
+
return { ok: true }
|
|
3238
|
+
})
|
|
3239
|
+
if (!claimed?.ok) return claimed
|
|
3240
|
+
if (decision === 'reject') {
|
|
3241
|
+
act('note', `已拒绝${target.kind}预览`, { reviewId: key })
|
|
3242
|
+
return { ok: true, item: { ...target, state: 'rejected' } }
|
|
3243
|
+
}
|
|
3244
|
+
const wrapped = backgroundToolRegistry.get(target.tool)
|
|
3245
|
+
let output
|
|
3246
|
+
const approvedArgs = { ...target.args }
|
|
3247
|
+
if (target.tool === 'bili_moment' && target.attachments?.length) {
|
|
3248
|
+
try {
|
|
3249
|
+
approvedArgs.images = target.attachments.map(item => {
|
|
3250
|
+
if (!/^[a-f0-9]{64}$/.test(item.hash) || !['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(item.fmt)) throw new Error('预览附图格式无效,未发送')
|
|
3251
|
+
const file = join(stateDir, 'send-review-media', item.hash + '.' + item.fmt)
|
|
3252
|
+
if (createHash('sha256').update(readFileSync(file)).digest('hex') !== item.hash) throw new Error('预览附图已丢失或变化,未发送')
|
|
3253
|
+
return file
|
|
3254
|
+
})
|
|
3255
|
+
} catch (error) { output = { ok: false, blocked: true, error: String(error?.message ?? error) } }
|
|
3256
|
+
}
|
|
3257
|
+
approvedReviewArgs.set(approvedArgs, target)
|
|
3258
|
+
const reviewed = { row: target, submitted: false }, reviewExec = {}
|
|
3259
|
+
approvedReviewExecutions.set(reviewExec, reviewed)
|
|
3260
|
+
try {
|
|
3261
|
+
if (!output) output = wrapped ? await wrapped.execute(approvedArgs, reviewExec) : { ok: false, blocked: true, error: '发送工具当前不可用,请重启 DSH 后重试' }
|
|
3262
|
+
}
|
|
3263
|
+
catch (error) { output = { ok: false, blocked: !reviewed.submitted, error: String(error?.message ?? error), unknown: reviewed.submitted } }
|
|
3264
|
+
finally { approvedReviewArgs.delete(approvedArgs); approvedReviewExecutions.delete(reviewExec); reviewSending.delete(key) }
|
|
3265
|
+
const sent = Boolean(output && !output.error && !output.blocked && !output.queued && (output.success === true || output.ok === true))
|
|
3266
|
+
const rejected = output?.guard_status === 'rejected' || output?.raw?.guard_status === 'rejected'
|
|
3267
|
+
const unknown = output?.unknown || (!sent && !output?.blocked && reviewed.submitted && !rejected)
|
|
3268
|
+
const stateName = sent ? 'sent' : unknown ? 'unknown' : output?.blocked ? 'pending' : 'failed'
|
|
3269
|
+
await withStateLock(() => {
|
|
3270
|
+
const state = readReviewState()
|
|
3271
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3272
|
+
const index = rows.findIndex((item) => item?.id === key)
|
|
3273
|
+
if (index >= 0) rows[index] = { ...rows[index], state: stateName, result: safe(output), updatedAt: new Date().toISOString(), sentAt: sent ? new Date().toISOString() : '' }
|
|
3274
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3275
|
+
})
|
|
3276
|
+
act(sent ? 'done' : 'error', `${sent ? '已批准并发送' : '批准后发送失败'}${target.kind}预览`, { reviewId: key })
|
|
3277
|
+
return { ok: sent, sent, item: { ...target, state: stateName, result: safe(output) }, result: safe(output), error: sent ? '' : output?.hint || output?.error || '发送结果未知,请去 B站核对' }
|
|
3278
|
+
}
|
|
3279
|
+
|
|
2916
3280
|
/* ── ⏱️ 出手节奏(2026-10-21 使用者 m18802 第 1、2 条)────────────────────────
|
|
2917
3281
|
* 原话:「发表视频评论不要太频繁,如果短时间内连点,比如几分钟发十几条,系统就可能会判为机器」
|
|
2918
3282
|
* 「所有操作要放慢节奏,不要一次性大量发,要适当间隔一点时间」
|
|
@@ -3107,11 +3471,27 @@ export function apply(ctx, config) {
|
|
|
3107
3471
|
return Math.max(fromState, countPicks(reportFileOf(now)))
|
|
3108
3472
|
}
|
|
3109
3473
|
|
|
3474
|
+
/** 最近七天的事实回顾:只读日报、token 日账和任务明细,不用模型补写主题。 */
|
|
3475
|
+
const sevenDayRetrospective = (st, now = new Date()) => {
|
|
3476
|
+
let ledger = []
|
|
3477
|
+
try { ledger = tokenLedgerSnapshot(stateDir, { limit: 200 }).entries || [] } catch { ledger = [] }
|
|
3478
|
+
return buildSevenDayRetrospective({ now, tokenDays: st?.tokenDays || {}, ledger,
|
|
3479
|
+
readReport: (day) => {
|
|
3480
|
+
const file = join(outDir, `${day}.md`)
|
|
3481
|
+
let size
|
|
3482
|
+
try { size = statSync(file).size } catch (error) { if (error?.code === 'ENOENT') return ''; throw error }
|
|
3483
|
+
if (size > 512 * 1024) throw new Error(`日报超过 512 KiB 安全读取上限(${size} bytes)`)
|
|
3484
|
+
const fd = openSync(file, 'r')
|
|
3485
|
+
try { const bytes = Buffer.alloc(size); const n = readSync(fd, bytes, 0, size, 0); return bytes.subarray(0, n).toString('utf8') }
|
|
3486
|
+
finally { closeSync(fd) }
|
|
3487
|
+
} })
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3110
3490
|
/* ────────── 🗨️ 桌宠「说话」通道(2026-10-28,把已有数据翻译成人话) ──────────
|
|
3111
3491
|
* st.petEvent = { at, text, tone }:收工小结 / 闲聊 / 提醒统一走这里,PetRoot 监听
|
|
3112
3492
|
* .at 变化冒 toast。文案全部本地兜底 —— 收工时不想为一句词再等一次推理。 */
|
|
3113
|
-
const petEvent = (text, tone = 'info') => {
|
|
3114
|
-
try { bumpState({ petEvent: { at: Date.now(), text: String(text || ''), tone } }).catch(() => {}) } catch { /* 说不出来就算 */ }
|
|
3493
|
+
const petEvent = (text, tone = 'info', kind = tone === 'warn' ? 'error' : tone === 'good' ? 'complete' : 'note') => {
|
|
3494
|
+
try { bumpState({ petEvent: { at: Date.now(), text: String(text || ''), tone, kind } }).catch(() => {}) } catch { /* 说不出来就算 */ }
|
|
3115
3495
|
try { act(tone === 'warn' ? 'error' : 'note', text) } catch { /* 流水写不进也不影响收工 */ }
|
|
3116
3496
|
}
|
|
3117
3497
|
const fmtRunLen = (sec) => {
|
|
@@ -3162,15 +3542,14 @@ export function apply(ctx, config) {
|
|
|
3162
3542
|
if (heads.length) {
|
|
3163
3543
|
const t = heads[Math.floor(Math.random() * heads.length)]
|
|
3164
3544
|
picks.push(
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
`今天刷到《${t}》的时候差点笑出声`,
|
|
3545
|
+
`小鱼书签夹在《${t}》这里了,报告里留了选它的理由。`,
|
|
3546
|
+
`想接着翻的话,可以从《${t}》开始;它在今天的精选里。`,
|
|
3168
3547
|
)
|
|
3169
3548
|
}
|
|
3170
3549
|
picks.push(
|
|
3171
3550
|
'鱼今天很闲,水换得勤快,泡泡也比平时圆',
|
|
3172
|
-
'
|
|
3173
|
-
'
|
|
3551
|
+
'鱼的今日难题:是先找有趣的东西,还是先想吃什么。',
|
|
3552
|
+
'给下一点好奇留了个座位,你忙你的,我在这儿。',
|
|
3174
3553
|
)
|
|
3175
3554
|
return picks[Math.floor(Math.random() * picks.length)]
|
|
3176
3555
|
} catch { return '' }
|
|
@@ -3410,7 +3789,105 @@ export function apply(ctx, config) {
|
|
|
3410
3789
|
return { command: 'dsh', args: [], shell: true, fallback: true, hint }
|
|
3411
3790
|
}
|
|
3412
3791
|
|
|
3413
|
-
|
|
3792
|
+
/* ═══════════════ 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」)═══════════════
|
|
3793
|
+
* ⚠️ **这一节有一道不可删的闸**:2026-10-21 的「一键升级」直接跑 `dsh plugin add`,
|
|
3794
|
+
* 把本机「目录联接 → 源码」的分叉整包换成了 registry 版本,改动全没(m17605,恢复花一整轮)。
|
|
3795
|
+
* 所以顺序永远是:**先判安装方式 → 再判有没有在跑 → 才允许升级**;
|
|
3796
|
+
* 本机链接/源码/未知安装一律只检查、不升级,把原因明说给使用者。
|
|
3797
|
+
* 检查走 npm registry 的 packument(只读、有超时和体积上限),不碰 GitHub API 限额。
|
|
3798
|
+
*/
|
|
3799
|
+
let upgrading = false
|
|
3800
|
+
const updateLogFile = join(stateDir, 'update.log')
|
|
3801
|
+
const appendUpdate = (line) => { try { appendFileSync(updateLogFile, line + '\n', 'utf8') } catch { /* 写不进去也不影响升级 */ } }
|
|
3802
|
+
/** 上一次检查结果(界面轮询 /status 时复用,不重复联网);10 分钟内有效。 */
|
|
3803
|
+
let updateCache = { at: 0, value: null }
|
|
3804
|
+
let updateCheckedAt = 0
|
|
3805
|
+
const updateRegistry = () => {
|
|
3806
|
+
const explicit = String(cfg.updateRegistry || '').trim()
|
|
3807
|
+
if (explicit) return explicit
|
|
3808
|
+
// 中国网络:默认按 npmmirror 查,避免 registry.npmjs.org 慢到 8 秒超时
|
|
3809
|
+
return String(cfg.updateMirror === false ? DEFAULT_REGISTRY : MIRROR_REGISTRY)
|
|
3810
|
+
}
|
|
3811
|
+
const updateBusy = () => ({
|
|
3812
|
+
installing,
|
|
3813
|
+
asr: installingAsr,
|
|
3814
|
+
feiyu: Boolean(feiyuLive?.size),
|
|
3815
|
+
background: Boolean(backgroundRun?.active),
|
|
3816
|
+
video: Boolean(pv.status()?.running),
|
|
3817
|
+
})
|
|
3818
|
+
const runUpdateCheck = async ({ force = false } = {}) => {
|
|
3819
|
+
if (!force && updateCache.value && Date.now() - updateCache.at < 10 * 60_000) return updateCache.value
|
|
3820
|
+
let report
|
|
3821
|
+
try {
|
|
3822
|
+
report = await checkForUpdate({
|
|
3823
|
+
current: myVersion(),
|
|
3824
|
+
pluginDir,
|
|
3825
|
+
dshHome: String(ctx?.get?.('dshHomePath')?.('') || process.env.DSH_HOME || ''),
|
|
3826
|
+
registry: updateRegistry(),
|
|
3827
|
+
packageName: PACKAGE_NAME,
|
|
3828
|
+
})
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
// 检查自己坏了也不能让设置页 500 —— 如实说"查不了"
|
|
3831
|
+
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' }
|
|
3832
|
+
}
|
|
3833
|
+
updateCache = { at: Date.now(), value: report }
|
|
3834
|
+
updateCheckedAt = Date.now()
|
|
3835
|
+
return report
|
|
3836
|
+
}
|
|
3837
|
+
const updateStatus = () => ({
|
|
3838
|
+
current: myVersion(),
|
|
3839
|
+
...(updateCache.value || {}),
|
|
3840
|
+
upgrading,
|
|
3841
|
+
checkedAt: updateCheckedAt || 0,
|
|
3842
|
+
})
|
|
3843
|
+
const startUpgrade = async () => {
|
|
3844
|
+
const report = updateCache.value || await runUpdateCheck()
|
|
3845
|
+
const blocker = upgradeBlocker({ report, busy: updateBusy(), upgrading })
|
|
3846
|
+
// ⚠️ 顺序不能反:`updateStatus()` 里带着检查结果的 `ok:true`,
|
|
3847
|
+
// 必须把它展开在**前面**,再写本次升级的 ok/code/error,否则失败会被覆盖成成功。
|
|
3848
|
+
if (blocker) return { ...updateStatus(), ok: false, code: 'UPGRADE_BLOCKED', error: blocker }
|
|
3849
|
+
const dsh = dshCliCached()
|
|
3850
|
+
const command = upgradeCommand({
|
|
3851
|
+
dshCli: dsh, profile: report.install?.profile, packageName: PACKAGE_NAME,
|
|
3852
|
+
electron: Boolean(process.versions.electron), registry: report.registry,
|
|
3853
|
+
})
|
|
3854
|
+
if (command.ok === false) return { ...updateStatus(), ok: false, code: command.code, error: command.error }
|
|
3855
|
+
upgrading = true
|
|
3856
|
+
appendUpdate(upgradeLogLine({ phase: 'start', detail: `${report.current} → ${report.latest}(profile ${command.profile})` }))
|
|
3857
|
+
// 升级要几十秒,HTTP 不能一直挂着:起完就返回,进度看 /update?rev 或日志
|
|
3858
|
+
const child = spawn(command.command, command.args, {
|
|
3859
|
+
cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
3860
|
+
env: uvEnv({ ...command.env, BILI_DUTY_HOME: stateDir }),
|
|
3861
|
+
})
|
|
3862
|
+
const chunks = []
|
|
3863
|
+
const collect = (buf) => { if (chunks.length < 400) chunks.push(String(buf)); appendUpdate(upgradeLogLine({ phase: 'out', detail: String(buf).trim() })) }
|
|
3864
|
+
child.stdout?.on('data', collect)
|
|
3865
|
+
child.stderr?.on('data', collect)
|
|
3866
|
+
let finished = false
|
|
3867
|
+
const timer = setTimeout(() => { if (!finished) { try { child.kill() } catch { /* 已经退出 */ } } }, 5 * 60_000)
|
|
3868
|
+
timer?.unref?.()
|
|
3869
|
+
child.on('error', (error) => {
|
|
3870
|
+
if (finished) return
|
|
3871
|
+
finished = true; clearTimeout(timer); upgrading = false
|
|
3872
|
+
const result = summarizeUpgrade({ code: -1, output: String(error?.message || error) })
|
|
3873
|
+
appendUpdate(upgradeLogLine({ phase: 'error', detail: result.message }))
|
|
3874
|
+
log(`🔄 升级进程起不来:${result.message}`)
|
|
3875
|
+
})
|
|
3876
|
+
child.on('close', (code) => {
|
|
3877
|
+
if (finished) return
|
|
3878
|
+
finished = true; clearTimeout(timer); upgrading = false
|
|
3879
|
+
const result = summarizeUpgrade({ code: Number(code ?? -1), output: chunks.join(''), latest: report.latest })
|
|
3880
|
+
appendUpdate(upgradeLogLine({ phase: result.ok ? 'done' : 'error', detail: result.message }))
|
|
3881
|
+
log(`🔄 升级${result.ok ? '完成' : '失败'}:${result.message}`)
|
|
3882
|
+
act('note', '更新通道:' + result.message)
|
|
3883
|
+
// 安装目录变了 ⇒ 旧结果作废,下一次检查重新问 registry
|
|
3884
|
+
updateCache = { at: 0, value: null }
|
|
3885
|
+
})
|
|
3886
|
+
return { ok: true, started: true, from: report.current, to: report.latest, profile: command.profile, hint: '升级在后台进行,完成后需要完全重启 DSH', ...updateStatus() }
|
|
3887
|
+
}
|
|
3888
|
+
ctx.effect(() => () => { updateCache = { at: 0, value: null } }, 'bili-duty: 收更新检查缓存')
|
|
3889
|
+
|
|
3890
|
+
|
|
3414
3891
|
// 🐾 2026-10-26 桌宠化改造:签名加 opts —— 桌宠那条 /pet/start 会把「这一轮的 token 预算」
|
|
3415
3892
|
// 递进来(headless 子进程没有鲸鱼摸鱼那套 token 保险丝,预算只能写进提示词让模型自己见好就收)。
|
|
3416
3893
|
// 现有调用者不传参(opts.tokenBudget 为空 ⇒ 一行都不多),行为与从前完全一致。
|
|
@@ -3420,6 +3897,7 @@ export function apply(ctx, config) {
|
|
|
3420
3897
|
const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
|
|
3421
3898
|
const lines = [
|
|
3422
3899
|
CHARACTER_COMPASS,
|
|
3900
|
+
companionPrompt(cfg),
|
|
3423
3901
|
cfg.persona ? '【使用者为此角色补充的设定】\n' + cfg.persona : '',
|
|
3424
3902
|
/* ⭐ 2026-10-28「刷多久」语义:时长是工作量目标,不是上限。
|
|
3425
3903
|
* 旧版写「做完这一小段就结束进程」,模型刷一小段就自己退了 ⇒ 使用者看到
|
|
@@ -3430,7 +3908,7 @@ export function apply(ctx, config) {
|
|
|
3430
3908
|
? `本次是时段任务:要一直刷到 ${new Date(opts.windowEndAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}(还剩约 ${Math.max(1, Math.round((opts.windowEndAt - Date.now()) / 60_000))} 分钟),`
|
|
3431
3909
|
: Number(opts.durationMinutes) > 0 ? `本次计划刷约 ${opts.durationMinutes} 分钟,` : '本轮刷到节奏账本建议收工为止,')
|
|
3432
3910
|
+ '做完一轮(找片 → 读字幕 → 挑着互动 → 记一句)就接着开下一轮,中间按节奏闸自然休息;时间没到就继续找新视频、翻评论区、整理待审材料,别提前收工。先读章程 ' + duty + '。',
|
|
3433
|
-
buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir }),
|
|
3911
|
+
buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir, recentOpenings: recentMomentOpenings() }),
|
|
3434
3912
|
'按自己的兴趣找近7天的新视频,读字幕原文后判断;没找到合适的可以空手。可爱、吐槽和闲话都行,不靠固定自称、颜文字或关键词证明人格。',
|
|
3435
3913
|
'开关只代表允许:不要求每挑一条就评、不要求发动态、不要求每轮学习或写心情。发送未知是否成功不能重发;收到节奏或冷却拒绝就换只读的事做。',
|
|
3436
3914
|
'本进程通过宿主工具桥调用同一套鲸鱼摸鱼工具,以实际注册说明为准:bili_buzzwords 看热词,bili_seen 查刷过的片,bili_say 留选片旁白;bili_learn 只收集待审材料,不能自行批准;bili_soul 只记真实互动感受。发送前问 bili_pace,评论用 bili_reply / bili_reply_opus,私信开着时用 bili_dm_inbox / bili_dm_reply。共享开关、判重与节奏账本,未授权工具不可用。',
|
|
@@ -3584,6 +4062,7 @@ export function apply(ctx, config) {
|
|
|
3584
4062
|
* 统一收紧:**必须是对象**才算数(bili_reply_opus 一直就是这么判的,口径拉齐)。
|
|
3585
4063
|
*/
|
|
3586
4064
|
const sendOk = (out) => Boolean(out) && typeof out === 'object' && !Array.isArray(out) && !out.error && out.success !== false
|
|
4065
|
+
&& (out.success === true || out.ok === true)
|
|
3587
4066
|
|
|
3588
4067
|
/* ⭐ 跑一轮的互斥 + 生命周期(2026-10-26):
|
|
3589
4068
|
* `runDuty` 有三条入口(bili_run 工具 / 设置页 POST /run / 谷时 tick),
|
|
@@ -4014,12 +4493,12 @@ export function apply(ctx, config) {
|
|
|
4014
4493
|
if (activeManualBrowsing()) return true
|
|
4015
4494
|
if (feiyuAgent) {
|
|
4016
4495
|
const g = ctx.get('goals')?.get(feiyuAgent)
|
|
4017
|
-
if (g &&
|
|
4496
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, feiyuAgent), g).enabled) return true
|
|
4018
4497
|
}
|
|
4019
4498
|
for (const id of feiyuLive.keys()) {
|
|
4020
4499
|
const a = ctx.get('agents')?.get?.(id)
|
|
4021
4500
|
const g = a ? ctx.get('goals')?.get(a) : null
|
|
4022
|
-
if (g &&
|
|
4501
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
|
|
4023
4502
|
}
|
|
4024
4503
|
// ⭐ 2026-10-20:进程重启后 `feiyuLive` 是空的、`feiyuAgent` 是 null,
|
|
4025
4504
|
// 但 goal 是**存在 session 里**的(不随进程消失)⇒ 只靠上面两圈会漏判,
|
|
@@ -4028,7 +4507,7 @@ export function apply(ctx, config) {
|
|
|
4028
4507
|
for (const a of (ctx.get('agents')?.list?.() ?? [])) {
|
|
4029
4508
|
const g = ctx.get('goals')?.get(a)
|
|
4030
4509
|
if (!g || g.phase !== 'active' || g.activation !== 'armed') continue
|
|
4031
|
-
if (
|
|
4510
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
|
|
4032
4511
|
}
|
|
4033
4512
|
return false
|
|
4034
4513
|
} catch { return false }
|
|
@@ -4131,13 +4610,13 @@ export function apply(ctx, config) {
|
|
|
4131
4610
|
&& countPickedToday(now) < Number(cfg.perDay || 0)
|
|
4132
4611
|
/* 🫧 闲聊泡泡:空闲时偶尔冒一句观察 —— 下一次开口时间抽好存进 state(不然每跳重抽
|
|
4133
4612
|
* 随机数,等效恒为下限 2.2 小时);免跑牌期间(用户说了「停」)绝不插话。 */
|
|
4134
|
-
if (!scheduleDue && cfg.idleChatter !== false && !(Number(st.bgWindowHold) > now.getTime())) {
|
|
4613
|
+
if (!scheduleDue && cfg.idleChatter !== false && companionPolicy(cfg, now).chatterAllowed && !(Number(st.bgWindowHold) > now.getTime())) {
|
|
4135
4614
|
const due = Number(st.petChatterAt) || 0
|
|
4136
4615
|
if (Date.now() >= due) {
|
|
4137
4616
|
const line = chatterLine()
|
|
4138
4617
|
if (line) {
|
|
4139
|
-
petEvent(line, 'info')
|
|
4140
|
-
bumpState({ petChatterAt:
|
|
4618
|
+
petEvent(line, 'info', 'chatter')
|
|
4619
|
+
bumpState({ petChatterAt: nextChatterAt(cfg) }).catch(() => {})
|
|
4141
4620
|
}
|
|
4142
4621
|
}
|
|
4143
4622
|
}
|
|
@@ -4267,7 +4746,7 @@ export function apply(ctx, config) {
|
|
|
4267
4746
|
const adoptFeiyu = (agent) => {
|
|
4268
4747
|
if (!agent) return null
|
|
4269
4748
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4270
|
-
if (preset.
|
|
4749
|
+
if (preset.id !== 'dafeiyu') return null
|
|
4271
4750
|
if (feiyuLive.has(agent.id)) return feiyuLive.get(agent.id)
|
|
4272
4751
|
let g = null
|
|
4273
4752
|
try { g = ctx.get('goals')?.get(agent) } catch { g = null }
|
|
@@ -4574,6 +5053,7 @@ export function apply(ctx, config) {
|
|
|
4574
5053
|
}
|
|
4575
5054
|
const beginBrowsingTurn = (agent, turn) => {
|
|
4576
5055
|
if (!agent?.id || !agent.session) return
|
|
5056
|
+
if (!isFishExecution({ agent })) return
|
|
4577
5057
|
let previous = browsingTurns.get(agent.id)
|
|
4578
5058
|
if (previous?.session !== agent.session) previous = null
|
|
4579
5059
|
// running is published before turn/start; the latter supplies identity, not
|
|
@@ -4740,6 +5220,32 @@ export function apply(ctx, config) {
|
|
|
4740
5220
|
})
|
|
4741
5221
|
const bumpMoment = () => bumpMomentBy(1)
|
|
4742
5222
|
|
|
5223
|
+
/**
|
|
5224
|
+
* 🪞 记下这条动态是**怎么开口的**,只留开头十几个字,最多留最近 5 条。
|
|
5225
|
+
*
|
|
5226
|
+
* 为什么要记:模型每轮都是新上下文,它**看不见自己上一条长什么样** ——
|
|
5227
|
+
* 光在章程里喊「别老用今天开头」是没用的,得把上一次的开头摆到它眼前。
|
|
5228
|
+
* 真机证据(2026-10-28):最近 12 条动态里 10 条以「今天看了/读了/挑了一条片/学到…」起头。
|
|
5229
|
+
*
|
|
5230
|
+
* ⚠️ 只存开头、不存全文:够它认出句式即可,存全文等于给它一份可抄的旧稿。
|
|
5231
|
+
* ⚠️ 只在**发送成功之后**记(失败/退回名额不该污染这面镜子)。
|
|
5232
|
+
*/
|
|
5233
|
+
const rememberMomentOpening = (text) => withStateLock(() => {
|
|
5234
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim()
|
|
5235
|
+
if (!flat) return
|
|
5236
|
+
const head = [...flat].slice(0, 14).join('')
|
|
5237
|
+
const st = readJson(dutyStateFile, {})
|
|
5238
|
+
const prev = Array.isArray(st.momentOpenings) ? st.momentOpenings : []
|
|
5239
|
+
const next = [...prev, head].slice(-MOMENT_OPENING_KEEP)
|
|
5240
|
+
writeJson(dutyStateFile, { ...st, momentOpenings: next })
|
|
5241
|
+
})
|
|
5242
|
+
|
|
5243
|
+
/** 最近几条动态的开头(给 `buildLiveBrief` 当镜子用;跨天也保留,疲劳是按条算的不是按天) */
|
|
5244
|
+
const recentMomentOpenings = () => {
|
|
5245
|
+
const st = readJson(dutyStateFile, {})
|
|
5246
|
+
return Array.isArray(st.momentOpenings) ? st.momentOpenings.slice(-MOMENT_OPENING_KEEP) : []
|
|
5247
|
+
}
|
|
5248
|
+
|
|
4743
5249
|
/**
|
|
4744
5250
|
* ⭐ 开一轮鲸鱼摸鱼模式:在**当前这个 agent** 上建一个 goal。
|
|
4745
5251
|
* 之后 goal-round-driver 会在每轮结束时自动续上 —— 我们什么都不用做。
|
|
@@ -4747,7 +5253,8 @@ export function apply(ctx, config) {
|
|
|
4747
5253
|
*/
|
|
4748
5254
|
const feiyuStart = (agent, opts = {}) => {
|
|
4749
5255
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4750
|
-
if (preset.
|
|
5256
|
+
if (preset.id !== DAFEIYU_PRESET_ID) return { ok: false, mode_required: true, hint: '先选择鲸鱼摸鱼模式,再明确说开始刷。' }
|
|
5257
|
+
if (!roleTools.refresh(agent)) return { ok: false, scope_unavailable: true, error: '宿主缺少会话工具作用域,已禁用对话内开刷;请更新 DSH。后台与定时设置仍可通过工作台使用。' }
|
|
4751
5258
|
refreshCfg()
|
|
4752
5259
|
const goals = ctx.get('goals')
|
|
4753
5260
|
if (!goals || typeof goals.create !== 'function') {
|
|
@@ -4868,7 +5375,8 @@ export function apply(ctx, config) {
|
|
|
4868
5375
|
|
|
4869
5376
|
const feiyuResume = (agent) => {
|
|
4870
5377
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4871
|
-
if (preset.
|
|
5378
|
+
if (preset.id !== DAFEIYU_PRESET_ID) return { ok: false, mode_required: true, hint: '先切回鲸鱼摸鱼模式,再继续刷。' }
|
|
5379
|
+
if (!roleTools.refresh(agent)) return { ok: false, scope_unavailable: true, error: '宿主缺少会话工具作用域,不能恢复对话内开刷,请更新 DSH。' }
|
|
4872
5380
|
const goals = ctx.get('goals')
|
|
4873
5381
|
const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
|
|
4874
5382
|
if (!g) return { ok: false, error: '这个会话没有 goal,先用 bili_feiyu 开一个' }
|
|
@@ -4947,6 +5455,29 @@ export function apply(ctx, config) {
|
|
|
4947
5455
|
}, 'agent-preset/selected')
|
|
4948
5456
|
} catch (error) { log('鲸鱼摸鱼模式退出监听未挂载:' + String(error?.message ?? error)) }
|
|
4949
5457
|
|
|
5458
|
+
// Preset events may predate plugin loading after restart. The native goal
|
|
5459
|
+
// driver owns its own objective prompt, so an empty persona section alone is
|
|
5460
|
+
// not sufficient: disarm only a stale fish goal before the next request.
|
|
5461
|
+
const enforceModeBoundary = agent => {
|
|
5462
|
+
if (!agent || personaScope(resolveSessionPreset(ctx, agent), null).enabled) return
|
|
5463
|
+
roleTools.detach(agent)
|
|
5464
|
+
cancelStickerSession(agent)
|
|
5465
|
+
finishManualBrowsing(agent, 'mode-exit')
|
|
5466
|
+
const goal = ctx.get('goals')?.get(agent)
|
|
5467
|
+
if (goal?.phase === 'active' && String(goal.objective || '').startsWith(FEIYU_MARK)) {
|
|
5468
|
+
const result = feiyuStop(agent, 'mode-exit')
|
|
5469
|
+
if (!result.ok) log('作用域边界暂停失败:' + String(result.error || 'unknown'))
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
try {
|
|
5473
|
+
onEvent('agent/created', ({ agent }) => enforceModeBoundary(agent), '鲸鱼目标重启隔离')
|
|
5474
|
+
onEvent('agent/pre-step', (step, next) => {
|
|
5475
|
+
enforceModeBoundary(step?.agent)
|
|
5476
|
+
return next()
|
|
5477
|
+
}, '鲸鱼目标请求隔离')
|
|
5478
|
+
for (const agent of (ctx.get('agents')?.list?.() || [])) enforceModeBoundary(agent)
|
|
5479
|
+
} catch (error) { log('鲸鱼目标作用域保护未挂载:' + String(error?.message ?? error)) }
|
|
5480
|
+
|
|
4950
5481
|
const feiyuStatus = (agent) => {
|
|
4951
5482
|
refreshCfg()
|
|
4952
5483
|
if (agent) adoptFeiyu(agent) // ⭐ 重启后第一次问状态时把老 goal 认领回来
|
|
@@ -5097,7 +5628,7 @@ export function apply(ctx, config) {
|
|
|
5097
5628
|
} : null)
|
|
5098
5629
|
if (!scope.enabled) return ''
|
|
5099
5630
|
if (!live && agent) live = adoptFeiyu(agent)
|
|
5100
|
-
const parts = [CHARACTER_COMPASS]
|
|
5631
|
+
const parts = [CHARACTER_COMPASS, companionPrompt(cfg), '【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle)]
|
|
5101
5632
|
// The early boundary also excludes memories and learning after explicit deselection.
|
|
5102
5633
|
if (cfg.persona && (inFeiyuPreset || reallyRunning)) {
|
|
5103
5634
|
parts.push('===== 你的人格设定(使用者写的,**按这个说话**)=====', cfg.persona, '===== 设定结束 =====')
|
|
@@ -5174,6 +5705,7 @@ export function apply(ctx, config) {
|
|
|
5174
5705
|
if (live && !live.stoppedAt && reallyRunning) {
|
|
5175
5706
|
parts.push(buildLiveBrief({
|
|
5176
5707
|
cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir,
|
|
5708
|
+
recentOpenings: recentMomentOpenings(),
|
|
5177
5709
|
}))
|
|
5178
5710
|
}
|
|
5179
5711
|
return parts.join('\n')
|
|
@@ -5315,6 +5847,7 @@ export function apply(ctx, config) {
|
|
|
5315
5847
|
description: '🐟 鲸鱼摸鱼模式:开始 / 停止 / 继续 / 状态 / 收台子',
|
|
5316
5848
|
input: { hint: '[start|stop|resume|status|clear]', attachments: false },
|
|
5317
5849
|
handler: ({ agent, rawInput }) => {
|
|
5850
|
+
if (!personaScope(resolveSessionPreset(ctx, agent), null).enabled) return { kind: 'error', text: '此命令仅在鲸鱼摸鱼模式内可用,请先切换模式。' }
|
|
5318
5851
|
const sub = String(rawInput || '').trim().toLowerCase() || 'start'
|
|
5319
5852
|
try {
|
|
5320
5853
|
if (sub === 'stop' || sub === 'pause' || sub === '停') {
|
|
@@ -5432,6 +5965,20 @@ export function apply(ctx, config) {
|
|
|
5432
5965
|
env: pyEnv(),
|
|
5433
5966
|
gsv: pvGsvOf,
|
|
5434
5967
|
})
|
|
5968
|
+
const publicVideoSources = (date) => readRecordEvents(stateDir, { date, type: 'activity', limit: 300, maxBytes: 512 * 1024 })
|
|
5969
|
+
.filter(row => !/(?:bili_dm_|private|inbox)/i.test(String(row.data?.tool || ''))
|
|
5970
|
+
&& !/^(?:dm|private)(?:[.:_-]|$)/i.test(String(row.data?.kind || '')) && !/私信/.test(String(row.data?.text || '')))
|
|
5971
|
+
.flatMap(row => {
|
|
5972
|
+
const data = row.data || {}
|
|
5973
|
+
const bvid = /BV[A-Za-z0-9]{10}/.exec(String(data.bvid || '') + ' ' + String(data.text || ''))?.[0]
|
|
5974
|
+
if (!bvid) return []
|
|
5975
|
+
return [{ id: row.eventId, eventId: row.eventId, date, bvid, kind: 'public-record',
|
|
5976
|
+
title: redactRecordText(data.title || '当日公开视频活动', { maxLength: 160 }),
|
|
5977
|
+
excerpt: redactRecordText(data.text || '', { maxLength: 1000 }),
|
|
5978
|
+
url: `https://www.bilibili.com/video/${bvid}` }]
|
|
5979
|
+
})
|
|
5980
|
+
const videoStudio = createVideoStudio({ ctx, pv, stateDir, config: () => cfg, publicSources: publicVideoSources, log })
|
|
5981
|
+
ctx.effect(() => () => videoStudio.dispose(), 'bili-duty: 收视频制作会话回执')
|
|
5435
5982
|
|
|
5436
5983
|
/**
|
|
5437
5984
|
* 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
|
|
@@ -5450,13 +5997,7 @@ export function apply(ctx, config) {
|
|
|
5450
5997
|
let uvOk = null
|
|
5451
5998
|
const haveUv = async () => {
|
|
5452
5999
|
if (uvOk) return true
|
|
5453
|
-
const ok = await
|
|
5454
|
-
try {
|
|
5455
|
-
const c = spawn('uv', ['--version'], { windowsHide: true, stdio: 'ignore' })
|
|
5456
|
-
c.on('exit', (code) => resolve(code === 0))
|
|
5457
|
-
c.on('error', () => resolve(false))
|
|
5458
|
-
} catch { resolve(false) }
|
|
5459
|
-
})
|
|
6000
|
+
const ok = await probeCmd('uv')
|
|
5460
6001
|
// ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
|
|
5461
6002
|
// 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
|
|
5462
6003
|
if (ok) uvOk = true
|
|
@@ -5493,14 +6034,14 @@ export function apply(ctx, config) {
|
|
|
5493
6034
|
}
|
|
5494
6035
|
|
|
5495
6036
|
/** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
|
|
5496
|
-
const probeCmd = (cmd) => new Promise((resolve) => {
|
|
6037
|
+
const probeCmd = (cmd, args = ['--version']) => new Promise((resolve) => {
|
|
5497
6038
|
let c = null
|
|
5498
6039
|
const finish = makeFinisher(resolve, 20_000, {
|
|
5499
6040
|
value: false,
|
|
5500
6041
|
onTimeout: () => { try { c?.kill('SIGKILL') } catch { /* 已经没了 */ } },
|
|
5501
6042
|
})
|
|
5502
6043
|
try {
|
|
5503
|
-
c = spawn(cmd,
|
|
6044
|
+
c = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
|
|
5504
6045
|
c.on('exit', (code) => finish(code === 0))
|
|
5505
6046
|
c.on('error', () => finish(false))
|
|
5506
6047
|
} catch { finish(false) }
|
|
@@ -5574,13 +6115,24 @@ export function apply(ctx, config) {
|
|
|
5574
6115
|
} catch { /* 轮转失败不该影响跑一轮 */ }
|
|
5575
6116
|
}
|
|
5576
6117
|
let installingAsr = false
|
|
6118
|
+
const asrInstallSnapshot = () => {
|
|
6119
|
+
try {
|
|
6120
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
6121
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('invalid state')
|
|
6122
|
+
const saved = state.asrInstall
|
|
6123
|
+
if (saved === undefined) return { state: 'unknown', phase: '', ok: null, running: false, error: '' }
|
|
6124
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved) || !['running', 'done', 'failed', 'interrupted'].includes(saved.state) || typeof saved.ok !== 'boolean') throw new Error('invalid ASR state')
|
|
6125
|
+
if (saved.running && !installingAsr) return { ...saved, state: 'interrupted', ok: false, running: false, error: '上次转写安装已中断,请重新安装转写依赖' }
|
|
6126
|
+
return saved
|
|
6127
|
+
} catch { return { state: 'failed', phase: 'state', ok: false, running: false, error: '转写安装状态损坏,未按就绪处理;请检查本地 state.json' } }
|
|
6128
|
+
}
|
|
6129
|
+
const asrEnvironment = () => inspectAsrEnvironment(asrDir, { installState: asrInstallSnapshot() })
|
|
5577
6130
|
|
|
5578
6131
|
/** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
|
|
5579
6132
|
const resolveUv = async () => {
|
|
5580
6133
|
const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
5581
6134
|
if (await haveUv()) return 'uv'
|
|
5582
|
-
|
|
5583
|
-
appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
|
|
6135
|
+
appendInstall(existsSync(bundled) ? '检查本机缓存 uv 的完整性和可运行状态…\n' : '这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员,只需要一次)…\n')
|
|
5584
6136
|
const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
|
|
5585
6137
|
if (got) appendInstall('uv 已就位:' + got + '\n')
|
|
5586
6138
|
return got
|
|
@@ -5613,15 +6165,24 @@ export function apply(ctx, config) {
|
|
|
5613
6165
|
installingAsr = true
|
|
5614
6166
|
appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
|
|
5615
6167
|
;(async () => {
|
|
6168
|
+
let installation = { state: 'running', phase: 'environment', ok: false, running: true, model: String(cfg.asrModel || 'small'), startedAt: new Date().toISOString(), finishedAt: '', error: '' }
|
|
6169
|
+
const transition = async patch => {
|
|
6170
|
+
installation = { ...installation, ...patch }
|
|
6171
|
+
await bumpState({ asrInstall: installation })
|
|
6172
|
+
}
|
|
5616
6173
|
try {
|
|
6174
|
+
await transition({})
|
|
6175
|
+
if (existsSync(asrDir) && lstatSync(asrDir).isSymbolicLink()) throw new Error('转写 .venv-asr 指向符号链接或目录联接;为保护外部环境,本次不安装。请先手动迁移为插件独立环境。')
|
|
5617
6176
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
5618
6177
|
const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
|
|
5619
6178
|
const py = asrPython()
|
|
5620
6179
|
const uvCmd = await resolveUv()
|
|
5621
6180
|
if (!existsSync(py)) {
|
|
5622
6181
|
// --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
|
|
5623
|
-
|
|
5624
|
-
|
|
6182
|
+
const created = uvCmd
|
|
6183
|
+
? await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
|
|
6184
|
+
: await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
|
|
6185
|
+
if (created !== 0) throw new Error('转写 Python 环境创建失败,请检查上方日志后重试')
|
|
5625
6186
|
}
|
|
5626
6187
|
if (!existsSync(py)) {
|
|
5627
6188
|
// ⭐ 2026-10-19(用户 `天文星球` 的截图):uv 报 `Could not acquire lock … uv\cache\.lock 拒绝访问 (os error 5)`
|
|
@@ -5631,8 +6192,9 @@ export function apply(ctx, config) {
|
|
|
5631
6192
|
appendAsr(' ① 上面日志里出现 "Could not acquire lock / uv\\cache\\.lock / 拒绝访问" ⇒ 是 uv 缓存目录没权限;\n')
|
|
5632
6193
|
appendAsr(' 装新版(0.1.11+)会把缓存改到插件自己的家目录,就不会再碰 %LOCALAPPDATA%\\uv 了。\n')
|
|
5633
6194
|
appendAsr(' ② 机器上没有可用的 uv 或 Python ⇒ 先点上面的「装依赖」,把主依赖装好再来。\n')
|
|
5634
|
-
|
|
6195
|
+
throw new Error('转写:建环境失败')
|
|
5635
6196
|
}
|
|
6197
|
+
await transition({ phase: 'packages' })
|
|
5636
6198
|
appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
|
|
5637
6199
|
let code = 1
|
|
5638
6200
|
// ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
|
|
@@ -5640,24 +6202,34 @@ export function apply(ctx, config) {
|
|
|
5640
6202
|
const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
|
|
5641
6203
|
if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
5642
6204
|
if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
5643
|
-
|
|
6205
|
+
await transition({ packageExitCode: code })
|
|
6206
|
+
if (code !== 0) throw new Error('转写装包失败,请检查上方日志后重试')
|
|
6207
|
+
await transition({ phase: 'imports' })
|
|
6208
|
+
const importCode = await spawnTo(py, ['-c', ASR_RUNTIME_CHECK], { sink: appendAsr })
|
|
6209
|
+
await transition({ importExitCode: importCode })
|
|
6210
|
+
if (importCode !== 0 || !inspectAsrEnvironment(asrDir).ready) throw new Error('转写运行依赖导入校验失败;不下载模型,也不标记就绪')
|
|
5644
6211
|
// ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
|
|
5645
|
-
const model =
|
|
6212
|
+
const model = installation.model
|
|
5646
6213
|
// 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
|
|
5647
6214
|
const hfHome = join(stateDir, '.hf-cache')
|
|
5648
6215
|
try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
|
|
5649
6216
|
appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
|
|
6217
|
+
await transition({ phase: 'model' })
|
|
5650
6218
|
const code2 = await spawnTo(py, ['-c',
|
|
5651
6219
|
'import os,sys;os.environ["HF_HUB_DISABLE_XET"]="1";from faster_whisper import WhisperModel;WhisperModel(sys.argv[1], device="cpu", compute_type="int8");print("模型就绪")',
|
|
5652
6220
|
model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
|
|
5653
|
-
|
|
6221
|
+
await transition({ modelExitCode: code2 })
|
|
6222
|
+
if (code2 !== 0) throw new Error('转写模型准备失败;不标记就绪,请检查日志后重试')
|
|
6223
|
+
await transition({ state: 'done', phase: 'done', ok: true, running: false, modelReady: true, finishedAt: new Date().toISOString() })
|
|
6224
|
+
appendAsr(`=== 安装校验完成(装包 exit ${code},导入 exit ${importCode},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
|
|
5654
6225
|
log(`装转写依赖结束:exit=${code}/${code2}`)
|
|
5655
6226
|
} catch (e) {
|
|
6227
|
+
try { await transition({ state: 'failed', ok: false, running: false, modelReady: false, error: String(e?.message || e), finishedAt: new Date().toISOString() }) } catch { /* Preserve damaged shared state; never replace it with an empty ledger. */ }
|
|
5656
6228
|
appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
|
|
5657
6229
|
log(`装转写依赖崩了:${e?.message ?? e}`)
|
|
5658
6230
|
} finally { installingAsr = false }
|
|
5659
6231
|
})()
|
|
5660
|
-
return { ok: true, started: true, log: asrLogFile, hint: '
|
|
6232
|
+
return { ok: true, started: true, completed: false, log: asrLogFile, hint: '已开始安装转写依赖;运行依赖与模型校验完成后才标记就绪,最终结果和失败原因显示在状态与日志中。' }
|
|
5661
6233
|
}
|
|
5662
6234
|
|
|
5663
6235
|
const runInstall = () => {
|
|
@@ -5665,23 +6237,38 @@ export function apply(ctx, config) {
|
|
|
5665
6237
|
installing = true
|
|
5666
6238
|
appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
|
|
5667
6239
|
;(async () => {
|
|
6240
|
+
const venvRoot = join(stateDir, '.venv')
|
|
6241
|
+
let backup = '', keepNewEnvironment = false
|
|
5668
6242
|
try {
|
|
5669
6243
|
const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
|
|
5670
6244
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
5671
6245
|
const venvPy = venvPython()
|
|
5672
6246
|
const py = String(cfg.pythonPath || 'python')
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
6247
|
+
if (existsSync(venvRoot) && lstatSync(venvRoot).isSymbolicLink()) throw new Error('插件 .venv 指向符号链接或目录联接;为保护外部环境,本次不安装。请先手动迁移为插件独立环境。')
|
|
6248
|
+
const before = mainEnvironment()
|
|
6249
|
+
const compatibleInterpreter = before.pythonCompatible && await probeCmd(venvPy, ['-c', PYTHON_COMPATIBILITY_CHECK])
|
|
6250
|
+
const needsCreation = !existsSync(venvRoot) || !compatibleInterpreter
|
|
6251
|
+
const uvCmd = await resolveUv()
|
|
6252
|
+
let bootstrapPython = ''
|
|
6253
|
+
if (needsCreation && !uvCmd) {
|
|
6254
|
+
for (const candidate of [...new Set([py, 'python', 'python3'])]) {
|
|
6255
|
+
if (candidate === venvPy) continue
|
|
6256
|
+
if (await probeCmd(candidate, ['-c', PYTHON_COMPATIBILITY_CHECK])) { bootstrapPython = candidate; break }
|
|
6257
|
+
}
|
|
6258
|
+
if (!bootstrapPython) throw new Error('没有可用的 uv 或 Python 3.11–3.13。请安装 uv 或兼容的 Python 后重试;现有环境未改动。')
|
|
5679
6259
|
}
|
|
5680
|
-
if (
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
6260
|
+
if (needsCreation) {
|
|
6261
|
+
if (existsSync(venvRoot)) {
|
|
6262
|
+
const destination = `${venvRoot}.backup-${randomUUID()}`
|
|
6263
|
+
appendInstall(`需要升级 Python(${before.pythonVersion || '版本无法确认'} → 3.11–3.13);先保留旧环境:${destination}\n`)
|
|
6264
|
+
if (mcp) { mcp.stop(); mcp = null; mcpKey = '' }
|
|
6265
|
+
renameSync(venvRoot, destination)
|
|
6266
|
+
backup = destination
|
|
6267
|
+
}
|
|
6268
|
+
const created = uvCmd
|
|
6269
|
+
? await spawnLogged(uvCmd, ['venv', '--python', MAIN_PYTHON_RANGE, '--seed', venvRoot])
|
|
6270
|
+
: await spawnLogged(bootstrapPython, ['-m', 'venv', venvRoot])
|
|
6271
|
+
if (created !== 0 || !existsSync(venvPy) || !(await probeCmd(venvPy, ['-c', PYTHON_COMPATIBILITY_CHECK]))) throw new Error('未能创建 Python 3.11–3.13 环境;未继续安装依赖。')
|
|
5685
6272
|
}
|
|
5686
6273
|
let code = 1
|
|
5687
6274
|
// ⭐ 2026-10-17(issue #8②):镜像也会整站拒绝(清华对部分 IP 回 403,而 pip 的报错写成"这个包不存在",很误导)
|
|
@@ -5690,16 +6277,21 @@ export function apply(ctx, config) {
|
|
|
5690
6277
|
for (let i = 0; i < mirrors.length && code !== 0; i++) {
|
|
5691
6278
|
const m = mirrors[i]
|
|
5692
6279
|
if (i > 0) appendInstall(`\n--- 换个镜像再试:${m} ---\n`)
|
|
5693
|
-
if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--python', venvPy, '-i', m, '-r', reqFile])
|
|
5694
|
-
if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '-i', m, '-r', reqFile])
|
|
6280
|
+
if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--require-hashes', '--python', venvPy, '-i', m, '-r', reqFile])
|
|
6281
|
+
if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '--require-hashes', '-i', m, '-r', reqFile])
|
|
5695
6282
|
}
|
|
5696
|
-
|
|
6283
|
+
const candidate = inspectMainEnvironment(venvRoot, { ignoreRuntimeFailure: true })
|
|
6284
|
+
const runtimeOk = code === 0 && candidate.ready && await probeCmd(venvPy, ['-c', MAIN_RUNTIME_CHECK])
|
|
6285
|
+
if (candidate.exists) atomicWriteFileSync(join(venvRoot, 'deepmoyu-health.json'), JSON.stringify({ ok: runtimeOk, at: new Date().toISOString() }))
|
|
6286
|
+
if (runtimeOk) {
|
|
5697
6287
|
// ⚠️ 光写 config.json 不够:DSH 原生设置里那份旧值("python")会在 loadCfg 时压回来
|
|
5698
6288
|
// (已由 loadCfg 的 HOST_WINS_EXCLUDED 解决 ✓);这里再把宿主的同步结果说清楚,
|
|
5699
6289
|
// 免得以后再出现"日志说设好了、实际没生效" ✗
|
|
5700
6290
|
const r = await persistCfg({ pythonPath: venvPy })
|
|
6291
|
+
keepNewEnvironment = true
|
|
5701
6292
|
appendInstall(`已把 pythonPath 设为 ${venvPy}(DSH 那边${r?.host?.ok ? '已同步' : '没写成:' + (r?.host?.error || '未知原因')})\n`)
|
|
5702
|
-
|
|
6293
|
+
if (backup) appendInstall(`升级完成;旧环境保留在 ${backup},未自动删除。\n`)
|
|
6294
|
+
} else throw new Error(`主环境安装或运行校验失败(exit ${code}):${mainEnvironment().upgradeHint || 'Python 或 Pillow 实际导入未通过'};不会标记为就绪。`)
|
|
5703
6295
|
{
|
|
5704
6296
|
const miss = missingVenvDeps()
|
|
5705
6297
|
appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()}${miss.length ? ',缺:' + miss.join(' / ') : ''})===\n`)
|
|
@@ -5708,9 +6300,23 @@ export function apply(ctx, config) {
|
|
|
5708
6300
|
} catch (e) {
|
|
5709
6301
|
appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
|
|
5710
6302
|
log(`装依赖崩了:${e?.message ?? e}`)
|
|
5711
|
-
} finally {
|
|
6303
|
+
} finally {
|
|
6304
|
+
if (backup && !keepNewEnvironment && existsSync(backup)) {
|
|
6305
|
+
try {
|
|
6306
|
+
if (existsSync(venvRoot)) {
|
|
6307
|
+
const failed = `${venvRoot}.failed-${randomUUID()}`
|
|
6308
|
+
renameSync(venvRoot, failed)
|
|
6309
|
+
appendInstall(`未完成的新环境保留在 ${failed}\n`)
|
|
6310
|
+
}
|
|
6311
|
+
renameSync(backup, venvRoot)
|
|
6312
|
+
appendInstall('本次升级未完成,已恢复原环境;仍需升级 Python / Pillow,未报告就绪。\n')
|
|
6313
|
+
} catch (error) { appendInstall(`自动恢复失败;原环境仍保留在 ${backup},请手动恢复:${error?.message || error}\n`) }
|
|
6314
|
+
}
|
|
6315
|
+
installing = false
|
|
6316
|
+
}
|
|
5712
6317
|
})()
|
|
5713
|
-
return { ok: true, started: true, log: installLogFile,
|
|
6318
|
+
return { ok: true, started: true, log: installLogFile, pythonRange: MAIN_PYTHON_RANGE, pillowMinimum: '13.0',
|
|
6319
|
+
upgradeRequired: mainEnvironment().upgradeRequired, hint: '正在安装并校验 Python 3.11–3.13 与 Pillow 13.0+;旧 Python 环境升级前会保留备份,进度显示在下面。' }
|
|
5714
6320
|
}
|
|
5715
6321
|
|
|
5716
6322
|
/** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
|
|
@@ -5780,6 +6386,16 @@ export function apply(ctx, config) {
|
|
|
5780
6386
|
} catch { return '' }
|
|
5781
6387
|
}
|
|
5782
6388
|
|
|
6389
|
+
const recoverySnapshot = ({ feiyu, schedule, state }) => {
|
|
6390
|
+
let videoRows = []
|
|
6391
|
+
try { videoRows = videoStudio.list() } catch { videoRows = [] }
|
|
6392
|
+
let lastBackground = null
|
|
6393
|
+
try { lastBackground = tokenLedgerSnapshot(stateDir, { limit: 40 }).entries.find((row) => row?.kind === 'background' && row?.endedAt) || null } catch { lastBackground = null }
|
|
6394
|
+
return buildRecoverySnapshot({ now: Date.now(), feiyu, background: backgroundRun, backgroundAlive: Boolean(dutyChild), lastBackground, schedule,
|
|
6395
|
+
scheduleEnabled: Boolean(cfg.scheduleEnabled), window: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(state?.bgWindowHold) || 0 },
|
|
6396
|
+
videoSessions: videoRows, lastRunAt: state?.lastRunAt || null })
|
|
6397
|
+
}
|
|
6398
|
+
|
|
5783
6399
|
/**
|
|
5784
6400
|
* ⭐ /status 增量短路(2026-10-27):前端 5 秒一问,99% 的时候什么都没变。
|
|
5785
6401
|
* statusRev() 只用**便宜信号**拼一个签名(一次 state.json + 几个 statSync + 内存扫描),
|
|
@@ -5804,7 +6420,10 @@ export function apply(ctx, config) {
|
|
|
5804
6420
|
JSON.stringify(st.pace || {}).length, JSON.stringify(st.seenVideos || {}).length,
|
|
5805
6421
|
JSON.stringify(st.repliedComments || {}).length, Number(st.lastRunAt) || 0)
|
|
5806
6422
|
bits.push(JSON.stringify(st.tokenDays || {}), Number(st.activeDays) || 0)
|
|
6423
|
+
bits.push(JSON.stringify(st.asrInstall || {}))
|
|
6424
|
+
bits.push(JSON.stringify((Array.isArray(st.sendReview) ? st.sendReview : []).map(row => [row?.id, row?.state, row?.hash, row?.updatedAt])))
|
|
5807
6425
|
bits.push(Number(st.bgWindowHold) || 0, Number(st.petEvent && st.petEvent.at) || 0)
|
|
6426
|
+
bits.push(String(st.tasteAt || ''), JSON.stringify(st.taste || {}))
|
|
5808
6427
|
/* ⏱️ 时钟派生字段的通解(2026-10-28 审查补漏):bgWindow.active / schedule 的
|
|
5809
6428
|
* due·nextRunAt / pace 倒计时 / attention 额度阈值都随墙钟走而账面不变 ——
|
|
5810
6429
|
* 分钟桶让 rev 每分钟翻一次,UI 的倒计时不再冻结。 */
|
|
@@ -5833,9 +6452,12 @@ export function apply(ctx, config) {
|
|
|
5833
6452
|
for (const file of [installLogFile, asrLogFile, join(stateDir, SOUL_FILE), join(stateDir, 'bili_credential.json')]) {
|
|
5834
6453
|
try { const s = statSync(file); bits.push(s.size, s.mtimeMs) } catch { bits.push(0) }
|
|
5835
6454
|
}
|
|
6455
|
+
try { const s = statSync(join(stateDir, 'pv-sessions.json')); bits.push(s.size, s.mtimeMs) } catch { bits.push(0) }
|
|
6456
|
+
try { const p = pv.status(); bits.push(String(p.id || ''), String(p.stage || ''), p.running ? 1 : 0, String(p.error || '').length) } catch { bits.push('pv!') }
|
|
5836
6457
|
try { bits.push(learnStats(asBook(snapshot.state.learn)).pending) } catch { bits.push('learn!') }
|
|
5837
6458
|
try { bits.push(String(snapshot.mirrorManifest?.at || '')) } catch { bits.push('mirror!') }
|
|
5838
6459
|
bits.push(modelCatalog.length, myVersion(), installing ? 1 : 0, installingAsr ? 1 : 0)
|
|
6460
|
+
bits.push(JSON.stringify(roleTools.snapshot()))
|
|
5839
6461
|
// All previous inputs remain covered without echoing the full config/ledger into the polling URL.
|
|
5840
6462
|
return createHash('sha256').update(JSON.stringify(bits)).digest('hex')
|
|
5841
6463
|
}
|
|
@@ -5847,6 +6469,11 @@ export function apply(ctx, config) {
|
|
|
5847
6469
|
// ⭐ findFeiyuAgent 要扫全部 agent,一次 status 只扫一遍(以前 feiyu/modelNow/tokenUsage 各扫一次)
|
|
5848
6470
|
let feiyuAgentNow = null
|
|
5849
6471
|
try { feiyuAgentNow = findFeiyuAgent() } catch { feiyuAgentNow = null }
|
|
6472
|
+
let feiyuNow
|
|
6473
|
+
try { feiyuNow = feiyuAgentNow ? feiyuStatus(feiyuAgentNow) : { ok: true, active: false, model: currentModel() } }
|
|
6474
|
+
catch { feiyuNow = { ok: true, active: false, model: '' } }
|
|
6475
|
+
const scheduleNow = scheduleSnapshot()
|
|
6476
|
+
const asrNow = asrEnvironment()
|
|
5850
6477
|
return {
|
|
5851
6478
|
ok: true,
|
|
5852
6479
|
home: stateDir,
|
|
@@ -5859,7 +6486,9 @@ export function apply(ctx, config) {
|
|
|
5859
6486
|
lastWhy: st.lastWhy || null,
|
|
5860
6487
|
loggedIn: hasCredential(),
|
|
5861
6488
|
venvReady: venvReadyNow(),
|
|
6489
|
+
environment: mainEnvironment(),
|
|
5862
6490
|
version: myVersion(),
|
|
6491
|
+
toolScope: roleTools.snapshot(),
|
|
5863
6492
|
// 2026-10-18:这几项"本地存了、但没能同步到 DSH 原生设置" ⇒ 设置页要告诉用户
|
|
5864
6493
|
// (DSH 0.2.1 某些启动方式会拒写:profile reload requires the root Include entry)
|
|
5865
6494
|
notSynced: Object.keys(readOverride()),
|
|
@@ -5869,9 +6498,12 @@ export function apply(ctx, config) {
|
|
|
5869
6498
|
installFailed: !installing && !venvReadyNow() && (() => { try { return statSync(installLogFile).size > 0 } catch { return false } })(),
|
|
5870
6499
|
installing,
|
|
5871
6500
|
// ── 本地转写那一套的状态(设置页要显示进度)──
|
|
5872
|
-
asrReady:
|
|
6501
|
+
asrReady: asrNow.ready,
|
|
6502
|
+
asrEnvironment: asrNow,
|
|
6503
|
+
asrInstall: asrNow.install,
|
|
5873
6504
|
// 模型下没下(免得界面写"就绪"、其实还得现下)
|
|
5874
|
-
asrModelReady:
|
|
6505
|
+
asrModelReady: existsSync(join(stateDir, '.hf-cache', 'hub')) && (asrNow.install.state === 'unknown'
|
|
6506
|
+
|| asrNow.install.ok === true && asrNow.install.modelReady === true && asrNow.install.model === String(cfg.asrModel || 'small')),
|
|
5875
6507
|
asrInstalling: installingAsr,
|
|
5876
6508
|
asrLog: asrLogFile,
|
|
5877
6509
|
asrTail: tailText(asrLogFile),
|
|
@@ -5889,12 +6521,16 @@ export function apply(ctx, config) {
|
|
|
5889
6521
|
bgWindow: { start: String(cfg.backgroundWindowStart || ''), end: String(cfg.backgroundWindowEnd || ''), active: Boolean(String(cfg.backgroundWindowStart || '') && String(cfg.backgroundWindowEnd || '') && backgroundWindowActive(cfg.backgroundWindowStart, cfg.backgroundWindowEnd)), holdUntil: Number(st.bgWindowHold) || 0 },
|
|
5890
6522
|
/* 🗨️🎨 显示层:petEvent 是桌宠「说话」的通道(收工小结/闲聊),mood 是心情色,
|
|
5891
6523
|
* attention 是「需要你」提醒 —— 三者都不影响任何行为。 */
|
|
5892
|
-
petEvent: st.petEvent && st.petEvent.at ? { at: Number(st.petEvent.at) || 0, text: String(st.petEvent.text || ''), tone: String(st.petEvent.tone || 'info') } : null,
|
|
6524
|
+
petEvent: st.petEvent && st.petEvent.at ? { at: Number(st.petEvent.at) || 0, text: String(st.petEvent.text || ''), tone: String(st.petEvent.tone || 'info'), kind: String(st.petEvent.kind || 'note') } : null,
|
|
6525
|
+
companion: companionPolicy(cfg),
|
|
5893
6526
|
mood: moodOf(),
|
|
5894
6527
|
attention: (() => {
|
|
5895
6528
|
const reasons = []
|
|
5896
6529
|
try {
|
|
5897
6530
|
if (!venvReadyNow()) reasons.push('主依赖还没装好 —— 「账号与环境」页点「装主依赖」')
|
|
6531
|
+
const previews = reviewSnapshot(false)
|
|
6532
|
+
if (previews.unknown > 0) reasons.push(`有 ${previews.unknown} 条发送结果需要核对 —— 「互动与投递」查看,别直接重发`)
|
|
6533
|
+
else if (previews.pending > 0) reasons.push(`有 ${previews.pending} 条内容等你预览 —— 「互动与投递」逐条批准或拒绝`)
|
|
5898
6534
|
const pending = Number(learnStats(learnLedger()).pending) || 0
|
|
5899
6535
|
if (pending >= 5) reasons.push(`学习队列积了 ${pending} 条待审 —— 顺手去「学习与记忆」翻一翻`)
|
|
5900
6536
|
const book = st.pace
|
|
@@ -5903,19 +6539,18 @@ export function apply(ctx, config) {
|
|
|
5903
6539
|
} catch { /* 读不到就不提醒,别让提醒本身变成故障 */ }
|
|
5904
6540
|
return { level: reasons.length ? 'soft' : 'none', reasons }
|
|
5905
6541
|
})(),
|
|
5906
|
-
schedule:
|
|
6542
|
+
schedule: scheduleNow,
|
|
5907
6543
|
learnReview: learningReviewStatus(),
|
|
6544
|
+
sendReview: (() => { try { return reviewSnapshot(false) } catch (error) { return { ok: false, enabled: Boolean(cfg.sendReview), error: String(error?.message ?? error), pending: null } } })(),
|
|
6545
|
+
retrospective: sevenDayRetrospective(st),
|
|
6546
|
+
recovery: recoverySnapshot({ feiyu: feiyuNow, schedule: scheduleNow, state: st }),
|
|
5908
6547
|
petShow: Boolean(cfg.petShow),
|
|
5909
6548
|
// ── 🐟 鲸鱼摸鱼模式(设置页要显示"现在是不是在刷")──
|
|
5910
6549
|
// ⭐ 用 findFeiyuAgent() 而不是裸 `feiyuAgent`:进程重启后这个变量是 null,
|
|
5911
6550
|
// 只认它会让设置页在真的还在刷的时候谎报「没在刷」(那个 goal 是活着的)。
|
|
5912
6551
|
// ⚠️ currentModel / tokensOf 都要**把 agent 传进去**:不传只能拿到全局默认模型,
|
|
5913
6552
|
// 界面上那个「当前对话在用的模型」就会是假的。
|
|
5914
|
-
feiyu:
|
|
5915
|
-
try {
|
|
5916
|
-
return feiyuAgentNow ? feiyuStatus(feiyuAgentNow) : { ok: true, active: false, model: currentModel() }
|
|
5917
|
-
} catch { return { ok: true, active: false, model: '' } }
|
|
5918
|
-
})(),
|
|
6553
|
+
feiyu: feiyuNow,
|
|
5919
6554
|
modelNow: currentModel(feiyuAgentNow || undefined),
|
|
5920
6555
|
// ⭐「指定已有模型」用:宿主手上真有的那些模型(取不到就是空数组,前端退回手填)
|
|
5921
6556
|
models: listModels(),
|
|
@@ -5992,7 +6627,8 @@ export function apply(ctx, config) {
|
|
|
5992
6627
|
* 「现在能不能发、还要等多久、风控冷却到几点」,不然使用者只会觉得"它卡住了"。 */
|
|
5993
6628
|
pace: (() => {
|
|
5994
6629
|
try {
|
|
5995
|
-
|
|
6630
|
+
const factor = paceFactor(), now = Date.now(), book = st.pace
|
|
6631
|
+
return { ok: true, factor, ...paceSummary(book, now, factor), explanation: paceExplain(book, now, factor) }
|
|
5996
6632
|
} catch (e) { return { ok: false, error: String(e?.message ?? e) } }
|
|
5997
6633
|
})(),
|
|
5998
6634
|
/* 🪞 她自己的账(2026-10-21 使用者第 3 条「更有人情味、能不断成长」)——
|
|
@@ -6001,6 +6637,26 @@ export function apply(ctx, config) {
|
|
|
6001
6637
|
soul: (() => {
|
|
6002
6638
|
try { return soulSnapshot() } catch (e) { return { ok: false, empty: true, text: '', error: String(e?.message ?? e) } }
|
|
6003
6639
|
})(),
|
|
6640
|
+
/* 🧠 记忆中心:把学习、心情、口味和判重账本放进同一份只读快照。
|
|
6641
|
+
* 文案由客户端组合,服务端只暴露经过 lib/taste.mjs 脱敏和阈值过滤的统计,
|
|
6642
|
+
* 不把整本 taste/state 原样塞进状态轮询。 */
|
|
6643
|
+
taste: (() => {
|
|
6644
|
+
try {
|
|
6645
|
+
const ledger = st.taste && typeof st.taste === 'object' ? st.taste : {}
|
|
6646
|
+
const stats = tasteStats(ledger)
|
|
6647
|
+
const digest = tasteDigest(ledger)
|
|
6648
|
+
return {
|
|
6649
|
+
sample: Number(stats.sample) || 0,
|
|
6650
|
+
checked: Number(stats.checked) || 0,
|
|
6651
|
+
minN: Number(stats.minN) || TASTE_MIN_N,
|
|
6652
|
+
enough: Boolean(digest.enough),
|
|
6653
|
+
lines: Array.isArray(digest.lines) ? digest.lines.slice(0, 4) : [],
|
|
6654
|
+
rows: Array.isArray(stats.rows) ? stats.rows.slice(0, 8) : [],
|
|
6655
|
+
top: tasteTop(ledger, 3),
|
|
6656
|
+
updatedAt: String(st.tasteAt || ''),
|
|
6657
|
+
}
|
|
6658
|
+
} catch (e) { return { sample: 0, checked: 0, minN: TASTE_MIN_N, enough: false, lines: [], rows: [], top: [], updatedAt: '', error: String(e?.message ?? e) } }
|
|
6659
|
+
})(),
|
|
6004
6660
|
}
|
|
6005
6661
|
}
|
|
6006
6662
|
|
|
@@ -6011,11 +6667,14 @@ export function apply(ctx, config) {
|
|
|
6011
6667
|
try {
|
|
6012
6668
|
const origin = String(req.headers?.origin || '')
|
|
6013
6669
|
if (origin) {
|
|
6014
|
-
let host = ''
|
|
6015
|
-
try {
|
|
6670
|
+
let host = '', parsed = null
|
|
6671
|
+
try { parsed = new URL(origin); host = parsed.hostname } catch { host = '' }
|
|
6016
6672
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1' && host !== '[::1]') {
|
|
6017
6673
|
return sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
|
|
6018
6674
|
}
|
|
6675
|
+
// Another localhost port is another application's origin. Browser
|
|
6676
|
+
// requests carry Host; legacy in-process adapters may omit it.
|
|
6677
|
+
if (req.headers?.host && parsed?.host.toLowerCase() !== String(req.headers.host).toLowerCase()) return sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
|
|
6019
6678
|
}
|
|
6020
6679
|
} catch { /* 判不了就按老样子继续 */ }
|
|
6021
6680
|
refreshCfg()
|
|
@@ -6030,15 +6689,44 @@ export function apply(ctx, config) {
|
|
|
6030
6689
|
return sendJson(res, 200, { ...status(snapshot), rev })
|
|
6031
6690
|
}
|
|
6032
6691
|
|
|
6692
|
+
if (req.method === 'GET' && path === '/api/bili-duty/send-review/image') {
|
|
6693
|
+
const hash = String(url.searchParams.get('hash') || '')
|
|
6694
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) return sendJson(res, 404, { ok: false, error: 'unknown preview image' })
|
|
6695
|
+
const item = reviewBook().flatMap(row => row.attachments || []).find(image => image.kind === 'dynamic' && image.hash === hash)
|
|
6696
|
+
if (!item) return sendJson(res, 404, { ok: false, error: 'unknown preview image' })
|
|
6697
|
+
try {
|
|
6698
|
+
if (!['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(item.fmt)) return sendJson(res, 404, { ok: false, error: 'preview image format invalid' })
|
|
6699
|
+
const bytes = readFileSync(join(stateDir, 'send-review-media', hash + '.' + item.fmt))
|
|
6700
|
+
if (createHash('sha256').update(bytes).digest('hex') !== hash) return sendJson(res, 404, { ok: false, error: 'preview image changed' })
|
|
6701
|
+
res.writeHead(200, { 'content-type': item.mime, 'content-length': bytes.length, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store' }); res.end(bytes); return
|
|
6702
|
+
} catch { return sendJson(res, 404, { ok: false, error: 'preview image missing' }) }
|
|
6703
|
+
}
|
|
6704
|
+
if (path === '/api/bili-duty/send-review') {
|
|
6705
|
+
if (req.method === 'GET') return sendJson(res, 200, reviewSnapshot())
|
|
6706
|
+
if (req.method === 'POST') {
|
|
6707
|
+
let body
|
|
6708
|
+
try { body = await readBody(req, { strict: true }) }
|
|
6709
|
+
catch (error) { return sendJson(res, error.status || 400, { ok: false, code: error.code || 'INVALID_JSON', error: error.message }) }
|
|
6710
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return sendJson(res, 400, { ok: false, error: '请求必须是对象' })
|
|
6711
|
+
const result = await decideSendReview(body.id, String(body.decision || ''), body.expectedHash)
|
|
6712
|
+
return sendJson(res, result.ok ? 200 : result.already ? 409 : 400, { ...reviewSnapshot(), ...result })
|
|
6713
|
+
}
|
|
6714
|
+
}
|
|
6715
|
+
|
|
6033
6716
|
if (req.method === 'GET' && path === '/api/bili-duty/stickers') {
|
|
6034
|
-
|
|
6717
|
+
const status = url.searchParams.get('status') || 'approved'
|
|
6718
|
+
if (!['approved', 'pending', 'all'].includes(status)) return sendJson(res, 400, { ok: false, error: 'status 只认 approved / pending / all' })
|
|
6719
|
+
try { return sendJson(res, 200, stickerGallery(status)) }
|
|
6035
6720
|
catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6036
6721
|
}
|
|
6037
6722
|
if (req.method === 'GET' && path === '/api/bili-duty/stickers/image') {
|
|
6038
6723
|
try {
|
|
6039
|
-
const
|
|
6040
|
-
if (!
|
|
6041
|
-
const
|
|
6724
|
+
const status = url.searchParams.get('status') || 'approved'
|
|
6725
|
+
if (!['approved', 'pending', 'all'].includes(status)) return sendJson(res, 404, { ok: false, error: 'unknown sticker' })
|
|
6726
|
+
const image = readStickerImage(stateDir, url.searchParams.get('name'), url.searchParams.get('source') || undefined, { status })
|
|
6727
|
+
const hash = url.searchParams.get('hash')
|
|
6728
|
+
if (!image || (hash && image.etag !== '"' + hash + '"')) return sendJson(res, 404, { ok: false, error: 'unknown sticker' })
|
|
6729
|
+
const headers = { 'content-type': image.mime, 'x-content-type-options': 'nosniff', 'cache-control': image.status === 'pending' ? 'private, no-store' : 'private, max-age=300', etag: image.etag }
|
|
6042
6730
|
if (req.headers?.['if-none-match'] === image.etag) { res.writeHead(304, headers); res.end(); return }
|
|
6043
6731
|
res.writeHead(200, { ...headers, 'content-length': image.bytes.length })
|
|
6044
6732
|
res.end(image.bytes)
|
|
@@ -6049,10 +6737,12 @@ export function apply(ctx, config) {
|
|
|
6049
6737
|
const body = await readBody(req)
|
|
6050
6738
|
// The UI accepts public image URLs; library copying remains the private tool's existing capability.
|
|
6051
6739
|
const result = await collectSticker(stateDir, { name: body?.name, url: body?.url,
|
|
6052
|
-
description: body?.description, tags: body?.tags, bvid: body?.bvid, rpid: body?.rpid, capacity: cfg.stickerCapacity
|
|
6740
|
+
description: body?.description, tags: body?.tags, bvid: body?.bvid, rpid: body?.rpid, capacity: cfg.stickerCapacity,
|
|
6741
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview } })
|
|
6053
6742
|
if (!result.ok) return sendJson(res, 200, result)
|
|
6054
|
-
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, sourceUrl: result.sourceUrl,
|
|
6055
|
-
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6743
|
+
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, sourceUrl: result.sourceUrl, status: result.status,
|
|
6744
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6745
|
+
+ (result.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(result.hash)}` : '') })
|
|
6056
6746
|
}
|
|
6057
6747
|
if (req.method === 'POST' && path === '/api/bili-duty/stickers/upload') {
|
|
6058
6748
|
let body
|
|
@@ -6061,6 +6751,7 @@ export function apply(ctx, config) {
|
|
|
6061
6751
|
const result = uploadSticker(stateDir, body && typeof body === 'object' && !Array.isArray(body) ? {
|
|
6062
6752
|
name: body.name, filename: body.filename, dataBase64: body.dataBase64,
|
|
6063
6753
|
description: body.description, tags: body.tags, capacity: cfg.stickerCapacity,
|
|
6754
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview },
|
|
6064
6755
|
} : null)
|
|
6065
6756
|
if (!result.ok) {
|
|
6066
6757
|
const status = result.code === 'STICKER_TOO_LARGE' ? 413 : result.code === 'STICKER_IMAGE_INVALID' ? 415
|
|
@@ -6068,8 +6759,23 @@ export function apply(ctx, config) {
|
|
|
6068
6759
|
: result.code === 'STICKER_STORAGE' ? 500 : 400
|
|
6069
6760
|
return sendJson(res, status, result)
|
|
6070
6761
|
}
|
|
6071
|
-
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, mime: result.mime, source: 'collected',
|
|
6072
|
-
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6762
|
+
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, mime: result.mime, source: 'collected', status: result.status,
|
|
6763
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6764
|
+
+ (result.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(result.hash)}` : '') })
|
|
6765
|
+
}
|
|
6766
|
+
// These decisions belong to the manual settings UI, never to bili_sticker/automatic tools.
|
|
6767
|
+
if (req.method === 'POST' && ['/api/bili-duty/stickers/approve', '/api/bili-duty/stickers/reject'].includes(path)) {
|
|
6768
|
+
let body
|
|
6769
|
+
try { body = await readBody(req, { strict: true }) }
|
|
6770
|
+
catch (error) { return sendJson(res, error.status || 400, { ok: false, code: error.code || 'INVALID_JSON', error: error.message }) }
|
|
6771
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.expectedHash !== 'string' || !/^[a-f0-9]{64}$/.test(body.expectedHash)) {
|
|
6772
|
+
return sendJson(res, 400, { ok: false, code: 'STICKER_INPUT', error: '请提供待审核表情的 name 和 expectedHash,重新查看图片后再决定' })
|
|
6773
|
+
}
|
|
6774
|
+
const decide = path.endsWith('/approve') ? approveSticker : rejectSticker
|
|
6775
|
+
const result = decide(stateDir, body.name, { expectedHash: body.expectedHash })
|
|
6776
|
+
const status = result.ok ? 200 : result.code === 'STICKER_INPUT' ? 400 : result.code === 'STICKER_NOT_FOUND' ? 404
|
|
6777
|
+
: result.code === 'STICKER_STORAGE' ? 500 : 409
|
|
6778
|
+
return sendJson(res, status, result)
|
|
6073
6779
|
}
|
|
6074
6780
|
if (req.method === 'POST' && path === '/api/bili-duty/stickers/forget') {
|
|
6075
6781
|
const body = await readBody(req)
|
|
@@ -6119,6 +6825,96 @@ export function apply(ctx, config) {
|
|
|
6119
6825
|
}
|
|
6120
6826
|
|
|
6121
6827
|
|
|
6828
|
+
/* Combined memory search is explicitly requested by the settings page.
|
|
6829
|
+
* Ordinary status polling never loads voice/history or walks every memory row. */
|
|
6830
|
+
if (path === '/api/bili-duty/memory') {
|
|
6831
|
+
const readText = (name) => {
|
|
6832
|
+
const file = join(stateDir, name)
|
|
6833
|
+
try {
|
|
6834
|
+
const info = statSync(file)
|
|
6835
|
+
if (info.size > 128 * 1024) throw new Error('MEMORY_FILE_TOO_LARGE: ' + name)
|
|
6836
|
+
return { text: readFileSync(file, 'utf8'), at: info.mtime.toISOString() }
|
|
6837
|
+
} catch (error) { if (error?.code === 'ENOENT') return { text: '', at: '' }; throw error }
|
|
6838
|
+
}
|
|
6839
|
+
const projection = (options = {}) => {
|
|
6840
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
6841
|
+
const soul = readText(SOUL_FILE), voice = readText('voice.md')
|
|
6842
|
+
return memoryCenterSnapshot({ learn: state.learn, soulText: soul.text, soulAt: soul.at,
|
|
6843
|
+
voiceText: voice.text, voiceAt: voice.at, taste: state.taste, seen: state.seenVideos,
|
|
6844
|
+
events: [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
|
|
6845
|
+
...readRecordEvents(stateDir, { type: 'learning', limit: 50, maxBytes: 128 * 1024 })]
|
|
6846
|
+
.sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100),
|
|
6847
|
+
}, options)
|
|
6848
|
+
}
|
|
6849
|
+
if (req.method === 'GET') {
|
|
6850
|
+
const source = String(url.searchParams.get('source') || 'all')
|
|
6851
|
+
if (!MEMORY_SOURCES.includes(source)) return sendJson(res, 400, { ok: false, error: 'MEMORY_SOURCE_INVALID' })
|
|
6852
|
+
try { return sendJson(res, 200, projection({ source, filter: url.searchParams.get('filter'), offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') || 20 })) }
|
|
6853
|
+
catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6854
|
+
}
|
|
6855
|
+
if (req.method === 'POST') {
|
|
6856
|
+
const body = await readBody(req)
|
|
6857
|
+
const source = String(body?.source || ''), id = String(body?.id || ''), action = String(body?.action || '')
|
|
6858
|
+
const expected = String(body?.version || '')
|
|
6859
|
+
if (!['learning', 'soul', 'voice', 'taste'].includes(source) || !['forget', 'revoke'].includes(action) || !id || !/^[a-f0-9]{64}$/.test(expected)) {
|
|
6860
|
+
return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6861
|
+
}
|
|
6862
|
+
try {
|
|
6863
|
+
if (source === 'learning') {
|
|
6864
|
+
const result = await learnMutate((book) => {
|
|
6865
|
+
const row = book?.items?.[id]
|
|
6866
|
+
if (!row || memoryFingerprint(row) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6867
|
+
if (action === 'revoke' && row.state !== 'approved') return { ok: false, error: 'MEMORY_NOT_APPROVED' }
|
|
6868
|
+
return action === 'revoke' ? learnDecide(book, id, 'reject', '使用者从记忆中心撤销批准') : learnForget(book, { id })
|
|
6869
|
+
})
|
|
6870
|
+
if (result.ok === false) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6871
|
+
} else if (source === 'soul' || source === 'voice') {
|
|
6872
|
+
if (action !== 'forget') return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6873
|
+
const filename = source === 'soul' ? SOUL_FILE : 'voice.md'
|
|
6874
|
+
const result = withFileLockSync(join(stateDir, filename), () => {
|
|
6875
|
+
const current = readText(filename).text
|
|
6876
|
+
let next
|
|
6877
|
+
if (source === 'soul') {
|
|
6878
|
+
const match = /^(mood|crave|self|next):(\d{1,4})$/.exec(id)
|
|
6879
|
+
if (!match) return { ok: false, error: 'MEMORY_ACTION_INVALID' }
|
|
6880
|
+
const lines = soulSectionBody(current, match[1]).split(/\r?\n/)
|
|
6881
|
+
const index = Number(match[2])
|
|
6882
|
+
if (index >= lines.length || memoryFingerprint(lines[index]) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6883
|
+
lines.splice(index, 1)
|
|
6884
|
+
next = soulSetSection(current, match[1], lines.join('\n'))
|
|
6885
|
+
captureMemorySnapshot(stateDir, filename, current, { mode: 'dafeiyu', operation: 'before-manual-forget' })
|
|
6886
|
+
return writeSoul(stateDir, next)
|
|
6887
|
+
}
|
|
6888
|
+
if (!/^\d{1,5}$/.test(id)) return { ok: false, error: 'MEMORY_ACTION_INVALID' }
|
|
6889
|
+
const lines = current.split(/\r?\n/), index = Number(id)
|
|
6890
|
+
if (index >= lines.length || memoryFingerprint(lines[index]) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6891
|
+
lines.splice(index, 1); next = lines.join('\n')
|
|
6892
|
+
captureMemorySnapshot(stateDir, filename, current, { mode: 'dafeiyu', operation: 'before-manual-forget' })
|
|
6893
|
+
atomicWriteFileSync(join(stateDir, filename), next)
|
|
6894
|
+
return captureMemorySnapshot(stateDir, filename, next, { mode: 'dafeiyu', operation: 'manual-forget' })
|
|
6895
|
+
})
|
|
6896
|
+
if (!result.ok) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6897
|
+
} else {
|
|
6898
|
+
if (action !== 'forget' || !/^\d{1,32}$/.test(id)) return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6899
|
+
const result = await withStateLock(() => {
|
|
6900
|
+
const state = readJsonFileSync(dutyStateFile, {}), row = state.taste?.[id]
|
|
6901
|
+
if (!row || memoryFingerprint(row) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6902
|
+
const next = { ...state.taste }; delete next[id]
|
|
6903
|
+
state.taste = next; state.tasteAt = new Date().toISOString()
|
|
6904
|
+
writeJson(dutyStateFile, state)
|
|
6905
|
+
return { ok: true }
|
|
6906
|
+
})
|
|
6907
|
+
if (!result.ok) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6908
|
+
}
|
|
6909
|
+
const audit = act('note', `记忆中心:使用者${action === 'revoke' ? '撤销批准' : '忘记'}了一条 ${source} 记录(${id}),历史版本保留`,
|
|
6910
|
+
{ memoryAction: { source, id, action, version: expected, manual: true } })
|
|
6911
|
+
queueMirror('记忆中心更新')
|
|
6912
|
+
return sendJson(res, 200, { ok: true, source, id, action, auditRetained: Boolean(audit),
|
|
6913
|
+
...(audit ? {} : { warning: '当前记忆已更新,但本次操作审计没有成功归档,请查看运行日志' }) })
|
|
6914
|
+
} catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6915
|
+
}
|
|
6916
|
+
}
|
|
6917
|
+
|
|
6122
6918
|
/* 🪞 她自己的账 —— 设置页能看、也能替她划掉一节(写是鱼自己的事,人不代笔)。
|
|
6123
6919
|
* ⚠️ 只开放「忘掉」:让人能替她写心情,这本账立刻就不真了。 */
|
|
6124
6920
|
if (path === '/api/bili-duty/soul') {
|
|
@@ -6217,7 +7013,12 @@ export function apply(ctx, config) {
|
|
|
6217
7013
|
}
|
|
6218
7014
|
|
|
6219
7015
|
if (req.method === 'POST' && path === '/api/bili-duty/config') {
|
|
6220
|
-
const body = await readBody(req)
|
|
7016
|
+
const body = await readBody(req, { strict: true })
|
|
7017
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return sendJson(res, 400, { ok: false, error: '配置 PATCH 必须是对象' })
|
|
7018
|
+
if (Object.hasOwn(body, 'resetTokensTotal') && typeof body.resetTokensTotal !== 'boolean') return sendJson(res, 400, { ok: false, error: 'resetTokensTotal 必须是 true 或 false' })
|
|
7019
|
+
for (const key of Object.keys(body)) {
|
|
7020
|
+
if (Object.hasOwn(DEFAULTS, key) && typeof DEFAULTS[key] === 'boolean' && typeof body[key] !== 'boolean') return sendJson(res, 400, { ok: false, error: key + ' 必须是 true 或 false' })
|
|
7021
|
+
}
|
|
6221
7022
|
const patch = {}
|
|
6222
7023
|
/* ⭐ 「一共花了多少」清零:它不是配置项(不落 config.json,也不进 DSH 设置页),
|
|
6223
7024
|
* 只是一次动作 ⇒ 在这里单独处理,别混进 clampPatch/persistCfg 那套。 */
|
|
@@ -6261,13 +7062,13 @@ export function apply(ctx, config) {
|
|
|
6261
7062
|
act('note', '把「一共花了多少 token」清零了')
|
|
6262
7063
|
} catch (e) { log(`总 token 清零失败:${e?.message ?? e}`) }
|
|
6263
7064
|
}
|
|
6264
|
-
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel', 'idleChatter',
|
|
7065
|
+
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'sendReview', 'transcribe', 'asrModel', 'idleChatter',
|
|
6265
7066
|
...Object.keys(STICKER_DEFAULTS),
|
|
6266
7067
|
'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
|
|
6267
7068
|
'autoReplyDm', 'dmReplyPerDay', 'dmReplyFollowedOnly',
|
|
6268
7069
|
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound',
|
|
6269
7070
|
// 🐟 鲸鱼摸鱼模式那几个
|
|
6270
|
-
'persona', 'focus', 'autoMoment', 'momentPerDay', 'momentStyle', 'modelId', 'tokenBudget', 'totalTokenBudget', 'maxRounds',
|
|
7071
|
+
'persona', 'focus', 'autoMoment', 'momentPerDay', 'momentStyle', 'interactionStyle', 'modelId', 'tokenBudget', 'totalTokenBudget', 'maxRounds',
|
|
6271
7072
|
// ⏱️ 出手节奏的松紧(2026-10-21 m18802 第 1、2 条)
|
|
6272
7073
|
// ⚠️ 漏在这张表里 = 设置页画得出来、点了保存却被静默丢掉(dmReplyFollowedOnly 就漏过一次;
|
|
6273
7074
|
// 2026-10-26 复查又发现 pv 整组 8 个键 + overlayMcp 漏掉 ⇒ 自动做视频开关从来没存上过)
|
|
@@ -6276,8 +7077,12 @@ export function apply(ctx, config) {
|
|
|
6276
7077
|
'backgroundWindowStart', 'backgroundWindowEnd',
|
|
6277
7078
|
// 📁 记录镜像目录(留空 = 自动)
|
|
6278
7079
|
'mirrorDir',
|
|
7080
|
+
// 🔄 更新通道(2026-10-30)—— 漏表 = 设置页改了却存不上
|
|
7081
|
+
'updateRegistry', 'updateMirror',
|
|
6279
7082
|
// 🎬 视频日报那组(clampPatch 都逐个处理了,这里不认就永远存不上)
|
|
6280
7083
|
'pvAuto', 'pvAt', 'pvVoice', 'pvDir',
|
|
7084
|
+
// 制作整条视频的模型;旧 pvModelId 只在读取配置时迁移。
|
|
7085
|
+
'videoModelId',
|
|
6281
7086
|
// 🎙 GPT-SoVITS 三件套 + 跑它的 python
|
|
6282
7087
|
'pvGsvRepo', 'pvGsvRef', 'pvGsvPrompt', 'pvGsvPython',
|
|
6283
7088
|
// 🐾 桌宠显隐(2026-10-26 桌宠化改造):桌宠菜单「隐藏桌宠」→ 存这里。
|
|
@@ -6285,6 +7090,7 @@ export function apply(ctx, config) {
|
|
|
6285
7090
|
'petShow',
|
|
6286
7091
|
...Object.keys(RUN_DEFAULTS),
|
|
6287
7092
|
...Object.keys(AUTOMATION_DEFAULTS),
|
|
7093
|
+
...Object.keys(COMPANION_DEFAULTS),
|
|
6288
7094
|
// 🔗 headless 那一轮挂不挂原生 MCP
|
|
6289
7095
|
'overlayMcp']) {
|
|
6290
7096
|
if (k in body) patch[k] = body[k]
|
|
@@ -6292,7 +7098,10 @@ export function apply(ctx, config) {
|
|
|
6292
7098
|
// ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
|
|
6293
7099
|
// ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
|
|
6294
7100
|
if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
|
|
6295
|
-
if ('dmUid' in patch)
|
|
7101
|
+
if ('dmUid' in patch) {
|
|
7102
|
+
try { patch.dmUid = configuredUid(patch.dmUid) }
|
|
7103
|
+
catch { return sendJson(res, 400, { ok: false, code: 'CONFIG_UID_INVALID', error: '收件 UID 必须是十进制字符串或安全非负整数;不接受已丢失精度的 number。' }) }
|
|
7104
|
+
}
|
|
6296
7105
|
const { clean, host, clamped } = await persistCfg(patch)
|
|
6297
7106
|
log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
|
|
6298
7107
|
return sendJson(res, 200, { ok: true, config: cfg, host, clamped: clamped || [], limits: LIMITS })
|
|
@@ -6513,6 +7322,7 @@ export function apply(ctx, config) {
|
|
|
6513
7322
|
'(本包不含登录凭证;贴给别人前可以再自查一遍)',
|
|
6514
7323
|
'',
|
|
6515
7324
|
`插件版本:v${myVersion()} · 家目录:${stateDir}`,
|
|
7325
|
+
`工具作用域:${JSON.stringify(roleTools.snapshot())}`,
|
|
6516
7326
|
`dsh 命令:${line(dshCliCached()?.command || '(找不到)')} ok=${line(dshCliCached()?.ok)}`,
|
|
6517
7327
|
`pythonPath:${line(cfg.pythonPath)} · venvReady=${venvReadyNow()} 缺依赖:${missingVenvDeps().join(',') || '无'}`,
|
|
6518
7328
|
`登录:${hasCredential() ? '已登录' : '未登录'} · headlessProfile:${line(cfg.headlessProfile)} overlayMcp=${Boolean(cfg.overlayMcp)}`,
|
|
@@ -6539,55 +7349,50 @@ export function apply(ctx, config) {
|
|
|
6539
7349
|
return sendJson(res, 200, { ok: true, text, file: logFile })
|
|
6540
7350
|
}
|
|
6541
7351
|
|
|
6542
|
-
//
|
|
7352
|
+
// Only fixed server-owned directory selectors are accepted, never a client-supplied path.
|
|
6543
7353
|
if (req.method === 'POST' && path === '/api/bili-duty/open') {
|
|
6544
7354
|
const body = await readBody(req)
|
|
6545
|
-
const what =
|
|
7355
|
+
const what = body?.what === undefined ? 'out' : body.what
|
|
7356
|
+
if (typeof what !== 'string' || !['out', 'home', 'mirror', 'pv'].includes(what)) return sendJson(res, 400, { ok: false, code: 'OPEN_TARGET_INVALID', error: 'what 只认 out / home / mirror / pv' })
|
|
6546
7357
|
/* ⭐ `mirror`(m12873-3):打开「记录镜像」那个目录 ——
|
|
6547
|
-
* 它跟 out/digest 是两个地方(镜像在工作区根,产物在 bili-work 下),不能混。
|
|
7358
|
+
* 它跟 out/digest 是两个地方(镜像在工作区根,产物在 bili-work 下),不能混。
|
|
7359
|
+
* ⭐ `pv`:视频工作室的成片目录;只算服务端自己的 pvDirsOf,不接受任意路径。 */
|
|
6548
7360
|
const dir = what === 'home'
|
|
6549
7361
|
? stateDir
|
|
6550
7362
|
: what === 'mirror'
|
|
6551
7363
|
? (resolveMirrorDir(stateDir, cfg.mirrorDir) || outDir)
|
|
6552
|
-
:
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
* · 那个按钮会把整个 `node_modules\dsh-deepmoyu` 重装成官方原版 ——
|
|
6585
|
-
* 我们所有改动(判重、学习、镜像、节奏、鲸鱼摸鱼人格)**全在包目录里**,一次点击全没。
|
|
6586
|
-
* 这不是假设:2026-10-21 就真发生过一次(m17605),恢复花了整整一轮。
|
|
6587
|
-
* · 使用者明确要求「删除掉他的更新」⇒ 这不是"默认关掉",是**入口都不留**。
|
|
6588
|
-
* 以后要升级请用插件自己的快照(`devtools\backups\plugin-ours-*`)手动覆盖,
|
|
6589
|
-
* 不要再用任何"自动更新"。
|
|
6590
|
-
*/
|
|
7364
|
+
: what === 'pv'
|
|
7365
|
+
? pvDirsOf().out
|
|
7366
|
+
: outDir
|
|
7367
|
+
const result = await openDirectory(dir)
|
|
7368
|
+
if (result.ok) log('打开目录请求已由系统接收:' + result.via)
|
|
7369
|
+
else log('打开目录失败:' + result.error)
|
|
7370
|
+
return sendJson(res, 200, result)
|
|
7371
|
+
}
|
|
7372
|
+
|
|
7373
|
+
/* 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」;取代 2026-10-21 删掉的那两条)
|
|
7374
|
+
*
|
|
7375
|
+
* 2026-10-21 删掉的是 `POST /upgrade`(宿主直接跑 `dsh plugin add`)+ `GET /update`(问 npm/GitHub),
|
|
7376
|
+
* 因为那次点击把本机「目录联接 → 源码」的分叉整包换成了 registry 版本(m17605)。
|
|
7377
|
+
* 现在恢复,但**顺序反过来**:先由 lib/update.mjs 判定这份安装到底是不是 registry 安装,
|
|
7378
|
+
* 不是就只回检查结果 + 原因,canUpgrade=false,前端连按钮都禁用。
|
|
7379
|
+
* 另外两个闸:有刷站/后台/渲染在跑时拒绝;同一时间只允许一个升级进程。 */
|
|
7380
|
+
if (req.method === 'GET' && path === '/api/bili-duty/update') {
|
|
7381
|
+
const force = url.searchParams.get('force') === '1'
|
|
7382
|
+
const report = await runUpdateCheck({ force })
|
|
7383
|
+
const rev = String(Math.round(Number(updateCheckedAt) / 1000)) + ':' + (report.latest || '') + ':' + (upgrading ? 'u' : '-') + ':' + myVersion()
|
|
7384
|
+
if (url.searchParams.get('rev') === rev) return sendJson(res, 200, { ok: true, rev, unchanged: true })
|
|
7385
|
+
return sendJson(res, 200, { ...updateStatus(), rev, blocker: upgradeBlocker({ report, busy: updateBusy(), upgrading }) })
|
|
7386
|
+
}
|
|
7387
|
+
if (req.method === 'POST' && path === '/api/bili-duty/upgrade') {
|
|
7388
|
+
const body = await readBody(req)
|
|
7389
|
+
// 只接受"确认升级"这一个动作;任何额外字段(目标版本、路径、参数)一律忽略,
|
|
7390
|
+
// 避免把这条路由变成可执行任意 dsh 命令的入口。
|
|
7391
|
+
if (body && typeof body === 'object' && body.confirm !== true) {
|
|
7392
|
+
return sendJson(res, 400, { ok: false, code: 'UPGRADE_CONFIRM_REQUIRED', error: '升级需要显式确认(confirm: true)' })
|
|
7393
|
+
}
|
|
7394
|
+
return sendJson(res, 200, await startUpgrade())
|
|
7395
|
+
}
|
|
6591
7396
|
|
|
6592
7397
|
// 「测一下」:真跑一次,回答"能不能用"
|
|
6593
7398
|
if (req.method === 'POST' && path === '/api/bili-duty/check') {
|
|
@@ -6596,19 +7401,20 @@ export function apply(ctx, config) {
|
|
|
6596
7401
|
|
|
6597
7402
|
// ── 扫码登录(设置页用)──
|
|
6598
7403
|
if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
|
|
6599
|
-
const { key, url } =
|
|
7404
|
+
const attempt = await loginSession.start(), { key, url } = attempt
|
|
6600
7405
|
const png = await renderQrPng(cfg.pythonPath, stateDir, url)
|
|
7406
|
+
if (!loginSession.current(attempt)) return sendJson(res, 409, { ok: false, error: '登录请求已被新的二维码替代,请重新查看最新二维码' })
|
|
6601
7407
|
let qr = null
|
|
6602
7408
|
if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
|
|
6603
|
-
log(
|
|
7409
|
+
log(qr ? '已生成登录二维码(不记录登录 key 或 URL)' : '二维码图片未生成,请检查 qrcode 依赖(不记录登录 key 或 URL)')
|
|
6604
7410
|
return sendJson(res, 200, { ok: true, key, url, qr })
|
|
6605
7411
|
}
|
|
6606
7412
|
|
|
6607
7413
|
if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
|
|
6608
7414
|
const body = await readBody(req)
|
|
6609
|
-
const r = await
|
|
7415
|
+
const r = await loginSession.poll(String(body.key || ''))
|
|
6610
7416
|
if (r.state === 'ok') {
|
|
6611
|
-
const file =
|
|
7417
|
+
const file = r.file
|
|
6612
7418
|
log(`登录成功,凭证已写入 ${file}`)
|
|
6613
7419
|
return sendJson(res, 200, { ok: true, state: 'ok', file })
|
|
6614
7420
|
}
|
|
@@ -6629,33 +7435,32 @@ export function apply(ctx, config) {
|
|
|
6629
7435
|
gsvRepo: cfg.pvGsvRepo || '', gsvRef: cfg.pvGsvRef || '',
|
|
6630
7436
|
gsvPrompt: cfg.pvGsvPrompt || '', gsvPython: cfg.pvGsvPython || '',
|
|
6631
7437
|
yesterday: pvYesterday(), out: d.out, work: d.work, root: d.root,
|
|
7438
|
+
modelId: cfg.videoModelId || '', sessions: videoStudio.list(),
|
|
6632
7439
|
...s,
|
|
6633
7440
|
})
|
|
6634
7441
|
}
|
|
7442
|
+
if (req.method === 'POST' && path === '/api/bili-duty/pv/session-new') {
|
|
7443
|
+
const body = await readBody(req)
|
|
7444
|
+
return sendJson(res, 200, await videoStudio.start(body))
|
|
7445
|
+
}
|
|
6635
7446
|
if (req.method === 'POST' && path === '/api/bili-duty/pv') {
|
|
6636
7447
|
const body = await readBody(req)
|
|
6637
|
-
const what = String(body?.action || '
|
|
6638
|
-
if (what === 'stop') return sendJson(res, 200,
|
|
7448
|
+
const what = String(body?.action || 'session').toLowerCase()
|
|
7449
|
+
if (what === 'stop') return sendJson(res, 200, videoStudio.stop(String(body?.sessionId || '')))
|
|
6639
7450
|
refreshCfg()
|
|
6640
|
-
if (what === 'make')
|
|
6641
|
-
|
|
6642
|
-
if (!pvDateOk(day)) return sendJson(res, 200, { ok: false, error: `日期得是 YYYY-MM-DD(拿到的是 "${day}")` })
|
|
6643
|
-
const dep = pv.needsCoverRepair(day) ? await pv.checkCoverDeps() : await pv.checkDeps()
|
|
6644
|
-
if (!dep) return sendJson(res, 200, { ok: false, error: '渲染依赖没装齐(edge-tts / imageio-ffmpeg / Pillow),先点「装依赖」', python: pvPython() })
|
|
6645
|
-
return sendJson(res, 200, pv.start(day, { force: body?.force === true, noNet: body?.noNet === true, voice: cfg.pvVoice }))
|
|
6646
|
-
}
|
|
6647
|
-
return sendJson(res, 200, { ok: false, error: `不认识的 action:${what}` })
|
|
7451
|
+
if (what === 'make' || what === 'session') return sendJson(res, 200, await videoStudio.start(body))
|
|
7452
|
+
return sendJson(res, 200, { ok: false, error: `不认识的 action:${what};请用 session 新开制作会话` })
|
|
6648
7453
|
}
|
|
6649
7454
|
/* 把成品 mp4 递给浏览器 —— 设置页要能直接播。
|
|
6650
7455
|
* ⚠️ **必须支持 Range**:`<video>` 拖动进度条发的就是 `Range: bytes=…`,
|
|
6651
7456
|
* 不回 206 的话浏览器只能从头放到尾(大文件还会干脆不出画面)。
|
|
6652
|
-
*
|
|
6653
|
-
* 否则这个接口就是"读服务器上任何文件"的后门。 */
|
|
7457
|
+
* 只接受验证成片的 production id 或旧成片日期,不接受任意文件路径。 */
|
|
6654
7458
|
if (req.method === 'GET' && path === '/api/bili-duty/pv/video') {
|
|
6655
|
-
const
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
7459
|
+
const id = url.searchParams.get('id')
|
|
7460
|
+
const day = String(url.searchParams.get('day') || '')
|
|
7461
|
+
if (id === null && !pvDateOk(day)) return sendJson(res, 400, { ok: false, error: '请提供成片 id 或 YYYY-MM-DD 格式的 day' })
|
|
7462
|
+
const file = id === null ? pv.fileOf(day) : pv.productionFileOf(id)
|
|
7463
|
+
if (!file) return sendJson(res, 404, { ok: false, error: '还没有可播放的已完成视频' })
|
|
6659
7464
|
let size = 0
|
|
6660
7465
|
try { size = statSync(file).size } catch { return sendJson(res, 404, { ok: false, error: '这天还没有视频' }) }
|
|
6661
7466
|
const range = String(req.headers?.range || '')
|
|
@@ -6682,7 +7487,9 @@ export function apply(ctx, config) {
|
|
|
6682
7487
|
rs.on('error', () => { try { res.destroy() } catch { /* 对端走了 */ } })
|
|
6683
7488
|
return rs.pipe(res)
|
|
6684
7489
|
}
|
|
6685
|
-
|
|
7490
|
+
const download = url.searchParams.get('download') === '1'
|
|
7491
|
+
res.writeHead(200, { 'content-type': 'video/mp4', 'accept-ranges': 'bytes', 'content-length': String(size), 'cache-control': 'no-store',
|
|
7492
|
+
...(download ? { 'content-disposition': `attachment; filename="DeepMoYu-${String(day || id).replace(/[^0-9A-Za-z-]/g, "")}.mp4"` } : {}) })
|
|
6686
7493
|
const rs = createReadStream(file)
|
|
6687
7494
|
rs.on('error', () => { try { res.destroy() } catch { /* 对端走了 */ } })
|
|
6688
7495
|
return rs.pipe(res)
|
|
@@ -6715,13 +7522,15 @@ export function apply(ctx, config) {
|
|
|
6715
7522
|
path: '/api/bili-duty',
|
|
6716
7523
|
handler: async (req, res) => {
|
|
6717
7524
|
try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
|
|
6718
|
-
|
|
7525
|
+
const status = Number.isInteger(e?.status) && e.status >= 400 && e.status < 500 ? e.status : 500
|
|
7526
|
+
sendJson(res, status, { ok: false, error: e?.message ?? String(e) })
|
|
6719
7527
|
}
|
|
6720
7528
|
},
|
|
6721
7529
|
}), 'bili-duty: /api/bili-duty 路由')
|
|
6722
7530
|
} catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
|
|
6723
7531
|
|
|
6724
7532
|
ctx.effect(() => () => {
|
|
7533
|
+
loginSession.dispose()
|
|
6725
7534
|
stickerAutomation.stop()
|
|
6726
7535
|
for (const scope of stickerSessionScopes.values()) scope.abort(new Error('插件已卸载'))
|
|
6727
7536
|
stickerSessionScopes.clear()
|
|
@@ -6773,10 +7582,11 @@ export function apply(ctx, config) {
|
|
|
6773
7582
|
const due = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 0, 0)
|
|
6774
7583
|
if (now < due) return // 还没到点
|
|
6775
7584
|
const y = pvYesterday(now)
|
|
6776
|
-
if (!pv.needsWork(y)) return
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
7585
|
+
if (!pv.needsWork(y) || pv.productions().some(item => item.mode === 'daily' && item.date === y)) return
|
|
7586
|
+
// A durable session, including failed/paused authoring, prevents another paid request every tick.
|
|
7587
|
+
if (videoStudio.list().some(item => item.scheduled && item.date === y)) return
|
|
7588
|
+
const r = await videoStudio.start({ date: y, mode: 'daily', targetDurationSec: 90, scheduled: true })
|
|
7589
|
+
log(r.ok ? `🎬 自动制作 ${y}:已新开可见会话 ${r.sessionId}` : `🎬 自动做 ${y} 的视频没起来:${r.error}`)
|
|
6780
7590
|
} catch (e) { log(`🎬 视频定时器这一跳出错(已兜住):${e?.stack ?? e}`) }
|
|
6781
7591
|
}
|
|
6782
7592
|
const t = setInterval(() => { pvTick() }, 3 * 60_000)
|