dsh-deepmoyu 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +91 -153
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +141 -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 +1348 -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 +953 -270
- 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/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,8 @@ 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
|
+
import { buildSevenDayRetrospective, buildRecoverySnapshot } from './lib/product-status.mjs'
|
|
111
122
|
|
|
112
123
|
export const name = 'dafeiyu'
|
|
113
124
|
export const inject = ['webServer', 'tools', 'settings']
|
|
@@ -130,6 +141,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
130
141
|
night: z.boolean().default(true).extra('volatile', true),
|
|
131
142
|
/** 自动评论 @ 使用者(默认关 —— 会拿用户的号说话,必须他自己开) */
|
|
132
143
|
autoComment: z.boolean().default(false).extra('volatile', true),
|
|
144
|
+
/** 鱼模式的公开发送先落本地待审核队列;默认关闭,保持历史直发行为。 */
|
|
145
|
+
sendReview: z.boolean().default(false).extra('volatile', true),
|
|
133
146
|
/** 本地采图与新评论配图各自控制;不会打开评论权限。概率为 0–100,冷却单位秒。 */
|
|
134
147
|
stickerAutoCollect: z.boolean().default(STICKER_DEFAULTS.stickerAutoCollect).extra('volatile', true),
|
|
135
148
|
stickerCollectProbability: z.natural().default(STICKER_DEFAULTS.stickerCollectProbability).extra('volatile', true),
|
|
@@ -138,10 +151,12 @@ export const Config = !z ? undefined : z.object({
|
|
|
138
151
|
stickerSendProbability: z.natural().default(STICKER_DEFAULTS.stickerSendProbability).extra('volatile', true),
|
|
139
152
|
stickerSendCooldown: z.natural().default(STICKER_DEFAULTS.stickerSendCooldown).extra('volatile', true),
|
|
140
153
|
stickerCapacity: z.natural().default(STICKER_DEFAULTS.stickerCapacity).extra('volatile', true),
|
|
154
|
+
/** 新图先经本人批准;关闭只影响以后收的新图,不放行仍待审核的图片。 */
|
|
155
|
+
stickerReview: z.boolean().default(STICKER_DEFAULTS.stickerReview).extra('volatile', true),
|
|
141
156
|
/** ⭐ 私信投递:off=只发评论(现状)· only=只私信(完全不公开评论)· both=评论 + 私信 */
|
|
142
157
|
dmMode: z.string().default('off').extra('volatile', true),
|
|
143
158
|
/** ⭐ 私信发给谁的 UID(数字)。要在 dmMode 不是 off 时填 */
|
|
144
|
-
dmUid: z.natural().default(0).extra('volatile', true),
|
|
159
|
+
dmUid: z.union([z.natural(), z.string()]).default(0).extra('volatile', true),
|
|
145
160
|
/** 本地转写(没字幕的视频用 Whisper 转;默认关,要下模型) */
|
|
146
161
|
transcribe: z.boolean().default(false).extra('volatile', true),
|
|
147
162
|
/** 本地转写用哪个 Whisper 模型(tiny / base / small / medium) */
|
|
@@ -203,8 +218,10 @@ export const Config = !z ? undefined : z.object({
|
|
|
203
218
|
autoMoment: z.boolean().default(false).extra('volatile', true),
|
|
204
219
|
/** 每天最多发几条说说 */
|
|
205
220
|
momentPerDay: z.natural().default(1).extra('volatile', true),
|
|
206
|
-
/**
|
|
207
|
-
momentStyle: z.string().default('
|
|
221
|
+
/** 说说的口味:随心情(默认,不加任何倾向)/ 感想 / 吐槽 / 安利 / 冷知识 —— 只当软提示,不是模板 */
|
|
222
|
+
momentStyle: z.string().default('随心情').extra('volatile', true),
|
|
223
|
+
/** 鲸鱼的互动档位:只调整陪伴方式,不改变任何发送权限。 */
|
|
224
|
+
interactionStyle: z.string().default('companion').extra('volatile', true),
|
|
208
225
|
/** 指定模型(`provider/model`;**留空 = 用当前对话的模型**) */
|
|
209
226
|
modelId: z.string().default('').extra('volatile', true),
|
|
210
227
|
/** token 预算(0 = 不限;**这一次开跑之后**烧的量超了就自动停,不会把号刷爆) */
|
|
@@ -238,6 +255,8 @@ export const Config = !z ? undefined : z.object({
|
|
|
238
255
|
pvVoice: z.string().default('zh-CN-XiaoyiNeural').extra('volatile', true),
|
|
239
256
|
/** 视频放哪儿(留空 = 自动:反推出使用者工作区,落在工作区根) */
|
|
240
257
|
pvDir: z.string().default('').extra('volatile', true),
|
|
258
|
+
/** 制作整条视频的会话模型(provider/model;留空跟随来源会话或宿主默认模型)。 */
|
|
259
|
+
videoModelId: z.string().default('').extra('volatile', true),
|
|
241
260
|
/* ── 🎙 真·本鱼音色(GPT-SoVITS 零样本克隆)──────────────────────────
|
|
242
261
|
* 三件套都给齐了才走 GPT-SoVITS,缺一个就自动退回上面的 edge-tts ——
|
|
243
262
|
* 没装这套环境的机器照样能出片,不会因为音色做不了而整条卡死。 */
|
|
@@ -259,6 +278,13 @@ export const Config = !z ? undefined : z.object({
|
|
|
259
278
|
petShow: z.boolean().default(true).extra('volatile', true),
|
|
260
279
|
/* 🫧 空闲时的闲聊泡泡(低频自言自语;false = 安静模式) */
|
|
261
280
|
idleChatter: z.boolean().default(true).extra('volatile', true),
|
|
281
|
+
chatterFrequency: z.string().default('normal').extra('volatile', true),
|
|
282
|
+
notificationLevel: z.string().default('normal').extra('volatile', true),
|
|
283
|
+
focusMode: z.boolean().default(false).extra('volatile', true),
|
|
284
|
+
quietHoursEnabled: z.boolean().default(false).extra('volatile', true),
|
|
285
|
+
quietHoursStart: z.string().default('23:00').extra('volatile', true),
|
|
286
|
+
quietHoursEnd: z.string().default('08:00').extra('volatile', true),
|
|
287
|
+
petReducedMotion: z.boolean().default(false).extra('volatile', true),
|
|
262
288
|
sessionModelId: z.string().default('').extra('volatile', true),
|
|
263
289
|
sessionTokenBudget: z.natural().default(0).extra('volatile', true),
|
|
264
290
|
sessionMaxRounds: z.natural().default(64).extra('volatile', true),
|
|
@@ -320,29 +346,13 @@ export function apply(ctx, config) {
|
|
|
320
346
|
|
|
321
347
|
// ⭐ "就绪"必须检查依赖真的装上了,不能只看目录存不存在
|
|
322
348
|
// (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
349
|
// ⚠️ 2026-10-18 用户实测:徽章显示「环境已就绪」、B站也登录了,可一跑就 `ModuleNotFoundError: No module named 'mcp'`
|
|
340
350
|
// ⇒ 原判据**只查了 bilibili_api**(0.1.2 修"假就绪"时留下的口子)⇒ 缺 `mcp` / `qrcode` 也照样说就绪 ✗
|
|
341
351
|
// ⇒ 三个都得有:bilibili_api(查数据)· mcp(跑那一轮的 MCP 服务器)· qrcode(画登录二维码)
|
|
342
|
-
const
|
|
343
|
-
const missingVenvDeps = () =>
|
|
344
|
-
const venvReadyNow = () =>
|
|
345
|
-
const asrReadyNow = () =>
|
|
352
|
+
const mainEnvironment = () => inspectMainEnvironment(join(stateDir, '.venv'))
|
|
353
|
+
const missingVenvDeps = () => mainEnvironment().reasons
|
|
354
|
+
const venvReadyNow = () => mainEnvironment().ready
|
|
355
|
+
const asrReadyNow = () => asrEnvironment().ready
|
|
346
356
|
const cfgFile = join(stateDir, 'config.json')
|
|
347
357
|
/**
|
|
348
358
|
* 本地优先(2026-10-18,两个用户实证):DSH 0.2.1 某些启动方式下 ctx.settings.update 会抛
|
|
@@ -531,30 +541,45 @@ export function apply(ctx, config) {
|
|
|
531
541
|
* ② **定时器回调里绝不让异常外冒**(见 tick)—— 未捕获异常会杀掉整个宿主进程。
|
|
532
542
|
*/
|
|
533
543
|
const DEFAULTS = {
|
|
544
|
+
...COMPANION_DEFAULTS,
|
|
534
545
|
...RUN_DEFAULTS,
|
|
535
546
|
...AUTOMATION_DEFAULTS,
|
|
536
547
|
...STICKER_DEFAULTS,
|
|
537
|
-
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, transcribe: false, asrModel: 'small',
|
|
548
|
+
perDay: 5, peakValley: false, noon: true, night: true, autoComment: false, sendReview: false, transcribe: false, asrModel: 'small',
|
|
538
549
|
dmMode: 'off', dmUid: 0,
|
|
539
550
|
keywords: [], commentPerRound: 1, mentionName: '', pythonPath: 'python', dshBin: '', headlessProfile: 'headless', overlayMcp: true, workspace: '', reportLevel: 'full', replyToComments: false, replyPerRound: 1,
|
|
540
551
|
autoReplyDm: false, dmReplyPerDay: 0, dmReplyFollowedOnly: true,
|
|
541
552
|
// 🐟 鲸鱼摸鱼模式(键必须和 Config 里的一模一样,normalize 是按 base 的键挑的)
|
|
542
|
-
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '
|
|
553
|
+
persona: '', focus: '', autoMoment: false, momentPerDay: 1, momentStyle: '随心情', interactionStyle: 'companion', modelId: '', tokenBudget: 0, totalTokenBudget: 0, maxRounds: 64, mirrorDir: '',
|
|
543
554
|
// ⏱️ 出手节奏系数(1 = 标准;2 = 间隔翻倍;0.5 = 快一倍)—— 2026-10-21 使用者 m18802 第 1、2 条
|
|
544
555
|
paceFactor: 1,
|
|
545
556
|
// 🎬 昨日冲浪报告视频(2026-10-22)
|
|
546
|
-
pvAuto: false, pvAt: '10:30', pvVoice: 'zh-CN-XiaoyiNeural', pvDir: '',
|
|
557
|
+
pvAuto: false, pvAt: '10:30', pvVoice: 'zh-CN-XiaoyiNeural', pvDir: '', videoModelId: '',
|
|
547
558
|
// 🎙 真·本鱼音色(GPT-SoVITS 零样本克隆;三项齐全才启用,否则退回 edge-tts)
|
|
548
559
|
pvGsvRepo: '', pvGsvRef: '', pvGsvPrompt: '', pvGsvPython: '',
|
|
549
560
|
// 🐾 桌宠显隐(2026-10-26 桌宠化改造):默认展示;false = 桌宠菜单里点了「隐藏桌宠」,
|
|
550
561
|
// 设置页还有开关能唤回 —— 所以这个值必须走配置对账那套(loadCfg 按 DEFAULTS 的键挑)
|
|
551
562
|
petShow: true,
|
|
552
563
|
}
|
|
564
|
+
const configuredUid = value => {
|
|
565
|
+
const id = normalizeId(typeof value === 'string' && !value.trim() ? '0' : value)
|
|
566
|
+
const small = Number(id)
|
|
567
|
+
// Preserve compatible legacy safe integers; large decimal strings never become numeric identities.
|
|
568
|
+
return Number.isSafeInteger(small) ? small : id
|
|
569
|
+
}
|
|
570
|
+
let invalidRecipientWarned = false
|
|
553
571
|
const normalize = (src, base) => {
|
|
554
572
|
const out = { ...base }
|
|
555
573
|
for (const k of Object.keys(base)) {
|
|
556
574
|
const v = src?.[k]
|
|
557
575
|
if (v === undefined || v === null) continue
|
|
576
|
+
if (k === 'dmUid') {
|
|
577
|
+
try { out[k] = configuredUid(v) } catch {
|
|
578
|
+
out[k] = 0
|
|
579
|
+
if (!invalidRecipientWarned) { invalidRecipientWarned = true; log('配置的收件 UID 无效或已丢失精度,已暂停成果投递;请重新填写原始十进制字符串。') }
|
|
580
|
+
}
|
|
581
|
+
continue
|
|
582
|
+
}
|
|
558
583
|
if (Array.isArray(base[k])) { if (Array.isArray(v)) out[k] = v.filter((x) => typeof x === 'string') }
|
|
559
584
|
else if (typeof base[k] === 'number') { const n = Number(v); if (Number.isFinite(n)) out[k] = n }
|
|
560
585
|
else if (typeof base[k] === 'boolean') { if (typeof v === 'boolean') out[k] = v }
|
|
@@ -610,6 +635,7 @@ export function apply(ctx, config) {
|
|
|
610
635
|
if ('perDay' in p) p.perDay = clampNum('perDay', p.perDay, 0)
|
|
611
636
|
if ('reportLevel' in p) p.reportLevel = ['full', 'brief', 'off'].includes(String(p.reportLevel)) ? String(p.reportLevel) : 'full'
|
|
612
637
|
if ('replyToComments' in p) p.replyToComments = Boolean(p.replyToComments)
|
|
638
|
+
if ('sendReview' in p) p.sendReview = Boolean(p.sendReview)
|
|
613
639
|
if ('replyPerRound' in p) p.replyPerRound = clampNum('replyPerRound', p.replyPerRound, 1)
|
|
614
640
|
if ('commentPerRound' in p) p.commentPerRound = clampNum('commentPerRound', p.commentPerRound, 0)
|
|
615
641
|
// 💬 自动回私信:防的是「陌生人私信当聊天通道刷屏」和「一条都不回」两个极端
|
|
@@ -619,7 +645,9 @@ export function apply(ctx, config) {
|
|
|
619
645
|
// 🐟 鲸鱼摸鱼模式
|
|
620
646
|
if ('autoMoment' in p) p.autoMoment = Boolean(p.autoMoment)
|
|
621
647
|
if ('momentPerDay' in p) p.momentPerDay = clampNum('momentPerDay', p.momentPerDay, 0)
|
|
622
|
-
|
|
648
|
+
// 说说口味:白名单外一律回落「随心情」——那是"不加倾向",比强塞一个口味更安全
|
|
649
|
+
if ('momentStyle' in p) p.momentStyle = ['随心情', '感想', '吐槽', '安利', '冷知识'].includes(String(p.momentStyle)) ? String(p.momentStyle) : '随心情'
|
|
650
|
+
if ('interactionStyle' in p) p.interactionStyle = ['quiet', 'companion', 'creator', 'steward'].includes(String(p.interactionStyle)) ? String(p.interactionStyle) : 'companion'
|
|
623
651
|
if ('tokenBudget' in p) p.tokenBudget = clampNum('tokenBudget', p.tokenBudget, 0)
|
|
624
652
|
if ('totalTokenBudget' in p) p.totalTokenBudget = clampNum('totalTokenBudget', p.totalTokenBudget, 0)
|
|
625
653
|
if ('maxRounds' in p) p.maxRounds = clampNum('maxRounds', p.maxRounds, 1)
|
|
@@ -643,6 +671,13 @@ export function apply(ctx, config) {
|
|
|
643
671
|
}
|
|
644
672
|
if ('pvVoice' in p) p.pvVoice = String(p.pvVoice ?? '').trim().slice(0, 100) || 'zh-CN-XiaoyiNeural'
|
|
645
673
|
if ('pvDir' in p) p.pvDir = String(p.pvDir ?? '').trim().slice(0, 400)
|
|
674
|
+
if ('videoModelId' in p) {
|
|
675
|
+
const want = String(p.videoModelId ?? '').trim()
|
|
676
|
+
if (want.length > 200 || want && (want.indexOf('/') <= 0 || want.indexOf('/') === want.length - 1 || /\s/.test(want))) {
|
|
677
|
+
throw new Error('制作模型要写成 provider/model;留空跟随当前会话或宿主默认模型')
|
|
678
|
+
}
|
|
679
|
+
p.videoModelId = want
|
|
680
|
+
}
|
|
646
681
|
// 🎙 真·本鱼音色:三个字符串都是路径/原文,只剪长度(路径可能很长)
|
|
647
682
|
if ('pvGsvRepo' in p) p.pvGsvRepo = String(p.pvGsvRepo ?? '').trim().slice(0, 400)
|
|
648
683
|
if ('pvGsvRef' in p) p.pvGsvRef = String(p.pvGsvRef ?? '').trim().slice(0, 400)
|
|
@@ -650,11 +685,11 @@ export function apply(ctx, config) {
|
|
|
650
685
|
if ('pvGsvPython' in p) p.pvGsvPython = String(p.pvGsvPython ?? '').trim().slice(0, 400)
|
|
651
686
|
// 🐾 桌宠显隐:纯布尔,给什么是什么(桌宠菜单/设置页两处都可能写它)
|
|
652
687
|
if ('petShow' in p) p.petShow = Boolean(p.petShow)
|
|
653
|
-
return clampStickerSettings(clampAutomationSettings(clampRunSettings(p)))
|
|
688
|
+
return clampCompanionSettings(clampStickerSettings(clampAutomationSettings(clampRunSettings(p))))
|
|
654
689
|
}
|
|
655
690
|
|
|
656
|
-
const hostCfg = () => normalize(config, { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
|
|
657
|
-
const savedCfg = () => normalize(migrateRunSettings(readJson(cfgFile, {}), hostCfg()), { ...DEFAULTS })
|
|
691
|
+
const hostCfg = () => normalize(migrateVideoSettings(config), { ...DEFAULTS }) // 宿主那份(schema 校验过、默认值已填)
|
|
692
|
+
const savedCfg = () => normalize(migrateRunSettings(migrateVideoSettings(readJson(cfgFile, {})), hostCfg()), { ...DEFAULTS })
|
|
658
693
|
/**
|
|
659
694
|
* ⭐⭐ 0.1.11(2026-10-19 · 第三批用户报"开关还是回弹"之后改的)
|
|
660
695
|
* 0.1.9 / 0.1.10 一直想"跟宿主对账":宿主那边有值就以宿主为准 —— 但 DSH 0.2.1 的某些启动方式下
|
|
@@ -672,7 +707,7 @@ export function apply(ctx, config) {
|
|
|
672
707
|
let hostDiff = [] // 插件这份和 DSH 原生设置那份不一样、且以插件为准的键
|
|
673
708
|
let lastDiffSig = '' // 上一次记过日志的组合(同一种组合只写一行)
|
|
674
709
|
let touchedKeys = (() => {
|
|
675
|
-
const raw = readJson(cfgFile, {})
|
|
710
|
+
const raw = migrateVideoSettings(readJson(cfgFile, {}))
|
|
676
711
|
if (Array.isArray(raw._set)) return new Set(raw._set)
|
|
677
712
|
return new Set(Object.keys(DEFAULTS).filter((k) => k in raw && JSON.stringify(raw[k]) !== JSON.stringify(DEFAULTS[k])))
|
|
678
713
|
})()
|
|
@@ -706,11 +741,11 @@ export function apply(ctx, config) {
|
|
|
706
741
|
const row = settingsDesc()
|
|
707
742
|
// ⭐ 读回来统一过一遍范围:原生设置页那边我们是钳不到写的(见 clampPatch 的注释)
|
|
708
743
|
if (!row) return clampPatch(saved)
|
|
709
|
-
const user = row.user || {}
|
|
744
|
+
const user = migrateVideoSettings(row.user || {})
|
|
710
745
|
// ⭐ 2026-10-19(另一位用户的 dsh 给出的更彻底的一招):**别用闭包里那份 `config` 当宿主基准** ——
|
|
711
746
|
// 桌面版里它是挂载时的快照,之后写进 `cordis.patch.yml` 的值不会热刷新进来 ✗
|
|
712
747
|
// ⇒ 用 `describe()` 这一行里的 `value`(当前 patch 解析出来的现值)当基准,拿不到才退回 `config` ✓
|
|
713
|
-
const out = normalize(row.value && typeof row.value === 'object' ? row.value : config, { ...DEFAULTS })
|
|
748
|
+
const out = normalize(migrateVideoSettings(row.value && typeof row.value === 'object' ? row.value : config), { ...DEFAULTS })
|
|
714
749
|
// ⚠️ 2026-10-18(用户实证 + 他的 dsh 定位):`pythonPath` 是**装依赖时自动写的**,
|
|
715
750
|
// 但它一旦在 DSH 原生设置里有过值(默认 "python"),就会被宿主覆盖回来 ⇒
|
|
716
751
|
// 「重装依赖」日志写着"已把 pythonPath 设为 venv",实际生效的还是系统那个空壳 python
|
|
@@ -750,16 +785,19 @@ export function apply(ctx, config) {
|
|
|
750
785
|
// ⭐ 配置是"随时可能被设置页就地改掉"的东西 ⇒ 每个入口用之前重算一遍(cfg 这个对象要一直指向同一份)
|
|
751
786
|
const refreshCfg = () => { Object.assign(cfg, loadCfg()) }
|
|
752
787
|
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
788
|
/* ⭐ 拿文件锁写(2026-10-26):设置页是逐字段 PATCH 的,两个输入框并发保存时
|
|
757
789
|
* 读-改-写会互相覆盖(后一次把前一次的 _set 抹掉 ⇒ 那批键的裁决权被还给宿主 ⇒ "开关弹回")。
|
|
758
790
|
* 锁内同时把盘上已有的 _set 并进来 —— 只信内存那份会把别的进程刚记的键丢掉。 */
|
|
759
791
|
withFileLockSync(cfgFile, () => {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
792
|
+
const current = migrateVideoSettings(readJsonFileSync(cfgFile, {}))
|
|
793
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('CONFIG_JSON_INVALID: expected an object')
|
|
794
|
+
const next = normalize(patch, normalize(current, cfg))
|
|
795
|
+
const keys = new Set([...(Array.isArray(current._set) ? current._set : []), ...touchedKeys,
|
|
796
|
+
...Object.keys(patch || {})].filter(key => Object.hasOwn(DEFAULTS, key)))
|
|
797
|
+
writeJson(cfgFile, { ...current, ...next, _set: [...keys] })
|
|
798
|
+
// Publish the in-memory result only after the durable transaction succeeds.
|
|
799
|
+
Object.assign(cfg, next)
|
|
800
|
+
touchedKeys = keys
|
|
763
801
|
})
|
|
764
802
|
}
|
|
765
803
|
/**
|
|
@@ -803,6 +841,11 @@ export function apply(ctx, config) {
|
|
|
803
841
|
* 漏掉任何一处,正好就是「设置页改了没反应」那个 bug 本身 ⇒ 收成一个函数。)
|
|
804
842
|
*/
|
|
805
843
|
const persistCfg = async (patch) => {
|
|
844
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw Object.assign(new Error('配置 PATCH 必须是对象'), { status: 400, code: 'CONFIG_INPUT' })
|
|
845
|
+
for (const key of Object.keys(patch)) {
|
|
846
|
+
if (!Object.hasOwn(DEFAULTS, key)) throw Object.assign(new Error('未知配置字段:' + key), { status: 400, code: 'CONFIG_INPUT' })
|
|
847
|
+
if (typeof DEFAULTS[key] === 'boolean' && typeof patch[key] !== 'boolean') throw Object.assign(new Error(key + ' 必须是 true 或 false,不能使用字符串或数字'), { status: 400, code: 'CONFIG_INPUT' })
|
|
848
|
+
}
|
|
806
849
|
const clean = clampPatch(patch)
|
|
807
850
|
/* ⭐ 2026-10-20(m11206):钳制不再静默 —— 把「你要的」和「实际存下的」摆一起报给界面。
|
|
808
851
|
* 原来 perDay 填 100 被钳成 50,界面还回一句「已保存」,使用者只能靠肉眼发现「还是 50」。 */
|
|
@@ -813,6 +856,9 @@ export function apply(ctx, config) {
|
|
|
813
856
|
if (typeof clean[k] === 'number' && Number(want) !== clean[k]) clamped.push({ key: k, want, got: clean[k] })
|
|
814
857
|
}
|
|
815
858
|
saveCfg(clean)
|
|
859
|
+
if (Object.hasOwn(clean, 'chatterFrequency') || clean.idleChatter === true) {
|
|
860
|
+
await bumpState({ petChatterAt: nextChatterAt(cfg) })
|
|
861
|
+
}
|
|
816
862
|
const host = await saveCfgHost(clean)
|
|
817
863
|
// 宿主写成功 ⇒ 恢复对账;失败 ⇒ 记成"本地优先",别让界面把它们压回去
|
|
818
864
|
// ⭐ 无条件本地优先(2026-10-18 第二次修):
|
|
@@ -889,9 +935,15 @@ export function apply(ctx, config) {
|
|
|
889
935
|
}
|
|
890
936
|
|
|
891
937
|
const call = async (toolName, args, opts) => {
|
|
892
|
-
const
|
|
938
|
+
const execution = toolExecution.getStore()
|
|
939
|
+
const signal = execution?.signal
|
|
940
|
+
const assertScope = () => {
|
|
941
|
+
if (execution?.agent && !approvedReviewExecutions.has(execution) && !isFishExecution(execution)) throw Object.assign(new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,停止本次工具后续操作'), { code: 'MODE_REQUIRED' })
|
|
942
|
+
}
|
|
943
|
+
assertScope()
|
|
893
944
|
signal?.throwIfAborted()
|
|
894
945
|
const sending = ['bili_reply', 'bili_reply_opus', 'bili_send_message', 'bili_send_dynamic'].includes(toolName)
|
|
946
|
+
const reviewed = sending ? approvedReviewExecutions.get(toolExecution.getStore()) : null
|
|
895
947
|
if (sending) {
|
|
896
948
|
for (const file of [cfgFile, dutyStateFile]) {
|
|
897
949
|
const value = readJsonFileSync(file, {})
|
|
@@ -900,14 +952,31 @@ export function apply(ctx, config) {
|
|
|
900
952
|
}
|
|
901
953
|
const client = await ensureMcp()
|
|
902
954
|
signal?.throwIfAborted()
|
|
955
|
+
assertScope()
|
|
956
|
+
if (reviewed) {
|
|
957
|
+
refreshCfg()
|
|
958
|
+
const row = reviewed.row
|
|
959
|
+
const publicOk = cfg.dmMode !== 'only'
|
|
960
|
+
const allowed = row.tool === 'bili_deliver' ? ['both', 'only'].includes(cfg.dmMode) && readableId(cfg.dmUid) === row.targetUid
|
|
961
|
+
: row.tool === 'bili_dm_reply' ? Boolean(cfg.autoReplyDm)
|
|
962
|
+
: row.tool === 'bili_moment' ? Boolean(cfg.autoMoment)
|
|
963
|
+
: publicOk && Boolean(optionalId(row.args.rpid) === '0' ? cfg.autoComment : cfg.replyToComments)
|
|
964
|
+
if (!allowed) throw Object.assign(new Error('批准后发送权限已关闭或收件人已改变,内容没有提交'), { reviewBeforeSubmit: true })
|
|
965
|
+
reviewed.submitted = true
|
|
966
|
+
}
|
|
903
967
|
const result = await client.call(toolName, args, signal ? { ...opts, signal } : opts)
|
|
904
968
|
// A completed send must still be recorded even if stop arrived during IO.
|
|
905
969
|
// Reads may not resume a stopped wrapper and submit a new send afterwards.
|
|
906
|
-
if (!sending) signal?.throwIfAborted()
|
|
970
|
+
if (!sending) { signal?.throwIfAborted(); assertScope() }
|
|
907
971
|
return result
|
|
908
972
|
}
|
|
909
973
|
|
|
910
974
|
const hasCredential = () => existsSync(join(stateDir, 'bili_credential.json'))
|
|
975
|
+
const loginSession = createLoginSession({ start: loginStart, poll: loginPoll, save: credential => {
|
|
976
|
+
const execution = toolExecution.getStore()
|
|
977
|
+
if (execution?.agent && !isFishExecution(execution)) throw new Error('MODE_REQUIRED: 已退出鲸鱼摸鱼模式,未替换登录凭证')
|
|
978
|
+
return saveCredential(stateDir, credential)
|
|
979
|
+
} })
|
|
911
980
|
|
|
912
981
|
/* 表情采集独立队列;图片读取、遍历只在表情功能需要时发生,不进入 /status。 */
|
|
913
982
|
// goals.pause does not cancel in-flight tool IO. Keep each session's collection scope separate.
|
|
@@ -931,19 +1000,26 @@ export function apply(ctx, config) {
|
|
|
931
1000
|
stateDir,
|
|
932
1001
|
getConfig: () => { refreshCfg(); return cfg },
|
|
933
1002
|
onEvent: (kind, value) => {
|
|
934
|
-
if (kind === 'collected') act('note', `收了一张表情「${value.name}
|
|
1003
|
+
if (kind === 'collected') act('note', `收了一张表情「${value.name}」${value.status === 'pending' ? ',等你审核后再用' : ''}`, { bvid: value.bvid })
|
|
935
1004
|
else log(`表情自动化(${kind}):${value.error || ''}`)
|
|
936
1005
|
},
|
|
937
1006
|
})
|
|
938
|
-
|
|
939
|
-
|
|
1007
|
+
// Only the manual gallery explicitly opts into pending. Public/tool defaults remain sendable-only.
|
|
1008
|
+
const stickerGallery = (status = 'approved') => {
|
|
1009
|
+
const all = listStickers(stateDir, { status: 'all' })
|
|
1010
|
+
const visible = status === 'all' ? all : status === 'pending' ? all.filter(item => item.status === 'pending') : listStickers(stateDir)
|
|
940
1011
|
return {
|
|
941
1012
|
ok: true,
|
|
942
|
-
|
|
943
|
-
|
|
1013
|
+
status,
|
|
1014
|
+
stickers: visible.map(({ file, fmt, hash, ...item }) => ({ ...item,
|
|
1015
|
+
...(item.status === 'pending' ? { hash } : {}),
|
|
1016
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(item.name)}&source=${item.source}`
|
|
1017
|
+
+ (item.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(hash)}` : ''),
|
|
944
1018
|
removable: item.source === 'collected',
|
|
945
1019
|
})),
|
|
946
|
-
counts: { total: all.length, builtin: all.filter(item => item.source === 'builtin').length,
|
|
1020
|
+
counts: { total: all.length, builtin: all.filter(item => item.source === 'builtin').length,
|
|
1021
|
+
collected: all.filter(item => item.source === 'collected').length, pending: all.filter(item => item.status === 'pending').length,
|
|
1022
|
+
approved: all.filter(item => item.status === 'approved').length },
|
|
947
1023
|
capacity: cfg.stickerCapacity,
|
|
948
1024
|
settings: stickerSettings(cfg),
|
|
949
1025
|
automation: stickerAutomation.snapshot(),
|
|
@@ -978,19 +1054,35 @@ export function apply(ctx, config) {
|
|
|
978
1054
|
try { goal = exec?.agent ? ctx.get('goals')?.get(exec.agent) : null } catch { /* no goal */ }
|
|
979
1055
|
return personaScope(resolveSessionPreset(ctx, exec?.agent), goal).enabled
|
|
980
1056
|
}
|
|
1057
|
+
const roleTools = createToolScopeRegistry({ ctx, enabled: agent => isFishExecution({ agent }), log })
|
|
1058
|
+
try {
|
|
1059
|
+
onEvent('agent/created', ({ agent }) => roleTools.refresh(agent), '鲸鱼工具会话挂载')
|
|
1060
|
+
onEvent('agent/disposed', ({ agent }) => roleTools.detach(agent), '鲸鱼工具会话卸载')
|
|
1061
|
+
onEvent('agent-preset/selected', (sessionId) => {
|
|
1062
|
+
const agent = (ctx.get('agents')?.list?.() || []).find(item => item.id === sessionId || item.session?.id === sessionId)
|
|
1063
|
+
if (agent) roleTools.refresh(agent)
|
|
1064
|
+
}, '鲸鱼工具预设隔离')
|
|
1065
|
+
ctx.effect(() => () => roleTools.dispose(), '鲸鱼工具作用域清理')
|
|
1066
|
+
} catch (error) { log('鲸鱼工具作用域监听失败:' + String(error?.message ?? error)) }
|
|
981
1067
|
// Content reads and account interactions are evidence of browsing. Settings,
|
|
982
1068
|
// status, memory and bookkeeping tools alone do not turn a chat into a run.
|
|
983
1069
|
const browsingTools = new Set(['bili_search', 'bili_subtitle', 'bili_danmaku', 'bili_buzzwords',
|
|
984
1070
|
'bili_video_info', 'bili_rank', 'bili_hot_videos', 'bili_user_videos', 'bili_comments',
|
|
985
1071
|
'bili_received_replies', 'bili_my_notices', 'bili_my_dynamics', 'bili_opus_comments',
|
|
986
1072
|
'bili_my_video_comments', 'bili_reply', 'bili_reply_opus', 'bili_dm_inbox',
|
|
987
|
-
'bili_dm_thread', 'bili_dm_reply', 'bili_moment'])
|
|
1073
|
+
'bili_dm_thread', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
988
1074
|
const tool = (spec) => {
|
|
989
1075
|
const wrapped = {
|
|
990
1076
|
...spec,
|
|
991
1077
|
output: textOutput,
|
|
992
1078
|
// ⭐ 流水就记在这一层:18 只工具一个都跑不掉,也不用每只都写一遍 log
|
|
993
1079
|
async execute(args, exec) {
|
|
1080
|
+
const manuallyApproved = exec && approvedReviewExecutions.has(exec)
|
|
1081
|
+
if (!manuallyApproved && !isFishExecution(exec)) return { ok: false, blocked: true, reason: 'mode_required', hint: 'DeepMoYu 工具仅在鲸鱼摸鱼模式内生效;普通会话不会启用角色工具或记忆。' }
|
|
1082
|
+
if (['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'].includes(spec.name)) {
|
|
1083
|
+
const unauthorized = modelForceDecision(args)
|
|
1084
|
+
if (unauthorized) return unauthorized
|
|
1085
|
+
}
|
|
994
1086
|
if (['bili_soul', 'bili_learn', 'bili_taste', 'bili_records', 'bili_sticker'].includes(spec.name)) {
|
|
995
1087
|
const agent = exec?.agent
|
|
996
1088
|
let goal = null
|
|
@@ -1011,8 +1103,10 @@ export function apply(ctx, config) {
|
|
|
1011
1103
|
const id = `t${++actSeq}`
|
|
1012
1104
|
if (!quiet) act('call', describeCall(spec.name, args), { tool: spec.name, id })
|
|
1013
1105
|
try {
|
|
1014
|
-
const v = safe(await toolExecution.run(exec || {}, () => {
|
|
1106
|
+
const v = safe(await toolExecution.run(exec || {}, async () => {
|
|
1015
1107
|
exec?.signal?.throwIfAborted()
|
|
1108
|
+
const review = await queueSendReview(spec.name, args, exec)
|
|
1109
|
+
if (review) return review
|
|
1016
1110
|
return spec.execute(args, exec)
|
|
1017
1111
|
}))
|
|
1018
1112
|
if (!quiet) {
|
|
@@ -1028,7 +1122,7 @@ export function apply(ctx, config) {
|
|
|
1028
1122
|
},
|
|
1029
1123
|
}
|
|
1030
1124
|
backgroundToolRegistry.set(spec.name, wrapped)
|
|
1031
|
-
return
|
|
1125
|
+
return roleTools.register(defineTool(wrapped))
|
|
1032
1126
|
}
|
|
1033
1127
|
|
|
1034
1128
|
tool({
|
|
@@ -1929,8 +2023,8 @@ export function apply(ctx, config) {
|
|
|
1929
2023
|
if (!(isReply ? canReply : canNew) && !args.force) {
|
|
1930
2024
|
return {
|
|
1931
2025
|
ok: false, blocked: true, hint: isReply
|
|
1932
|
-
? '
|
|
1933
|
-
: '
|
|
2026
|
+
? '「自动回复评论」关着(或投递方式是「只发私信」),请本人在工作台调整,模型不能自授权限。'
|
|
2027
|
+
: '「自动评论视频」关着(或投递方式是「只发私信」),请本人在工作台调整,模型不能自授权限。',
|
|
1934
2028
|
}
|
|
1935
2029
|
}
|
|
1936
2030
|
// 内容闸门与 bili_reply 同一套(动态评论同样不能是纯口号)
|
|
@@ -1945,7 +2039,7 @@ export function apply(ctx, config) {
|
|
|
1945
2039
|
return {
|
|
1946
2040
|
ok: false, blocked: true, shallow: true, template: true, rule: t.rule,
|
|
1947
2041
|
reason: t.reason, marks: t.marks,
|
|
1948
|
-
hint: t.hint + '
|
|
2042
|
+
hint: t.hint + '(请改写正文;人工审核也不绕过内容校验。)',
|
|
1949
2043
|
}
|
|
1950
2044
|
}
|
|
1951
2045
|
} catch { /* 模板闸坏了不能挡住发评论 */ }
|
|
@@ -2072,13 +2166,13 @@ export function apply(ctx, config) {
|
|
|
2072
2166
|
+ '10 分钟最多 10 次出手。**没有每日条数上限 —— 想回就回,不想回就放着**。\n'
|
|
2073
2167
|
+ '⚠️ 但私信在 B站 举报权重里最重(是唯一直接推到别人手机的动作):**别对着同一个人连着发**,'
|
|
2074
2168
|
+ '同一个 `msg_seqno` 只回一次(宿主记着账本),回得短、像人说话。\n'
|
|
2075
|
-
+ '🔴
|
|
2169
|
+
+ '🔴 安全闸:「自动回复私信」默认关闭。只有本人在工作台手动打开后才允许发送;模型不能用 force 或配置 PATCH 绕过。\n'
|
|
2076
2170
|
+ '⚠️ 同一个 `msg_seqno` 只回一次(宿主记着账本,重复回会被挡)。**没有每日条数上限** —— 想回就回,不想回就放着。',
|
|
2077
2171
|
parameters: {
|
|
2078
2172
|
uid: { type: 'string', required: true, description: '发给谁(对方 UID)' },
|
|
2079
2173
|
text: { type: 'string', required: true, description: '私信正文(短、像人说话、按人格;别粘链接、别写手机号)' },
|
|
2080
2174
|
msg_seqno: { type: 'string', description: '回的是哪一条(从 bili_dm_inbox 拿;填了才判重,**建议一定要填**)' },
|
|
2081
|
-
force: { type: 'boolean', description: '
|
|
2175
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2082
2176
|
},
|
|
2083
2177
|
async execute(args) {
|
|
2084
2178
|
refreshCfg()
|
|
@@ -2092,7 +2186,7 @@ export function apply(ctx, config) {
|
|
|
2092
2186
|
if (!cfg.autoReplyDm && !args.force) {
|
|
2093
2187
|
return {
|
|
2094
2188
|
ok: false, blocked: true,
|
|
2095
|
-
hint: '
|
|
2189
|
+
hint: '工作台 → 互动与投递中的「自动回复私信」未开启,请本人手动调整;模型不能用 force 自授权限。',
|
|
2096
2190
|
}
|
|
2097
2191
|
}
|
|
2098
2192
|
// 闸门二:判重(同一条私信只回一次 —— 这是最容易被模型搞砸的地方)
|
|
@@ -2119,7 +2213,7 @@ export function apply(ctx, config) {
|
|
|
2119
2213
|
return {
|
|
2120
2214
|
ok: false, blocked: true, perUserCap: true, sentToday: used,
|
|
2121
2215
|
hint: `今天已经回过这个人 ${used} 条了 —— 再连着回就成刷屏了。等他有新消息再接,`
|
|
2122
|
-
+ '
|
|
2216
|
+
+ '或者把话留给还没搭过话的人;模型不能用 force 绕过这道防刷屏检查。',
|
|
2123
2217
|
}
|
|
2124
2218
|
}
|
|
2125
2219
|
/* 闸门四(2026-10-20 补):**按真实的收件箱状态复核一遍**。
|
|
@@ -2155,9 +2249,9 @@ export function apply(ctx, config) {
|
|
|
2155
2249
|
|
|
2156
2250
|
const r = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: seqno })
|
|
2157
2251
|
const o = typeof r === 'object' && r ? r : {}
|
|
2158
|
-
if (o.success === false || o.error) {
|
|
2252
|
+
if (o.success === false || o.error || !(o.success === true || o.ok === true)) {
|
|
2159
2253
|
const risk = paceRiskOf(o)
|
|
2160
|
-
return { ok: false, error: String(o.error || o.message || '
|
|
2254
|
+
return { ok: false, unknown: o.success !== false && !o.error, error: String(o.error || o.message || '发送结果未知'), raw: o, ...(risk ? { riskNote: risk } : {}) }
|
|
2161
2255
|
}
|
|
2162
2256
|
await paceDone('dm')
|
|
2163
2257
|
// 记账本:判重 + 当日额度(**发成功之后才记** —— 失败了记上就再也回不了)
|
|
@@ -2185,6 +2279,44 @@ export function apply(ctx, config) {
|
|
|
2185
2279
|
},
|
|
2186
2280
|
})
|
|
2187
2281
|
|
|
2282
|
+
tool({
|
|
2283
|
+
name: 'bili_deliver',
|
|
2284
|
+
description: '把本轮精选结果合并成一条私信投递给本人已配置的收件 UID。只在私信投递开关开启时可用;不接受任意收件人。最多 500 字,附标题、链接和简短理由,不放私密来信、路径或配置。发送预览开启时先等本人审核,批准仍经过原节奏和判重。',
|
|
2285
|
+
parameters: { text: { type: 'string', required: true, description: '合并后的成果私信,最多 500 字' } },
|
|
2286
|
+
async execute(args) {
|
|
2287
|
+
refreshCfg()
|
|
2288
|
+
const uid = readableId(cfg.dmUid, { allowZero: false })
|
|
2289
|
+
const text = typeof args?.text === 'string' ? args.text.trim() : ''
|
|
2290
|
+
if (!['only', 'both'].includes(cfg.dmMode) || !uid) return { ok: false, blocked: true, error: '私信投递未开启或未配置收件 UID' }
|
|
2291
|
+
if (!text || text.length > 500) return { ok: false, blocked: true, error: '成果私信需要 1–500 字正文' }
|
|
2292
|
+
const approved = approvedReviewArgs.get(args)
|
|
2293
|
+
if (approved && approved.targetUid !== uid) return { ok: false, blocked: true, error: '收件 UID 在预览后已改变,请重新生成预览' }
|
|
2294
|
+
const blocked = paceGate('dm')
|
|
2295
|
+
if (blocked) return blocked
|
|
2296
|
+
const key = createHash('sha256').update(JSON.stringify([dayKey(), uid, text])).digest('hex')
|
|
2297
|
+
const reservation = await withStateLock(() => {
|
|
2298
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
2299
|
+
const book = state.dmDeliveries && typeof state.dmDeliveries === 'object' ? state.dmDeliveries : {}
|
|
2300
|
+
if (book[key]) return { ok: false, blocked: true, duplicate: true, error: '这份结果已投递或正在投递;未知结果不重试', previous: book[key].state }
|
|
2301
|
+
book[key] = { state: 'sending', uid, at: new Date().toISOString() }
|
|
2302
|
+
writeJson(dutyStateFile, { ...state, dmDeliveries: book })
|
|
2303
|
+
return { ok: true }
|
|
2304
|
+
})
|
|
2305
|
+
if (!reservation.ok) return reservation
|
|
2306
|
+
let result
|
|
2307
|
+
try { result = await call('bili_send_message', { receiver_uid: uid, text, msg_seqno: '0' }) }
|
|
2308
|
+
catch (error) { result = { ok: false, unknown: true, error: String(error?.message ?? error) } }
|
|
2309
|
+
const success = Boolean(result && !result.error && (result.success === true || result.ok === true))
|
|
2310
|
+
await withStateLock(() => {
|
|
2311
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
2312
|
+
writeJson(dutyStateFile, { ...state, dmDeliveries: { ...(state.dmDeliveries || {}), [key]: { state: success ? 'sent' : 'unknown', uid, at: new Date().toISOString() } } })
|
|
2313
|
+
})
|
|
2314
|
+
if (success) await paceDone('dm')
|
|
2315
|
+
else paceRiskOf(result)
|
|
2316
|
+
return success ? { ok: true, success: true, uid } : { ok: false, unknown: true, error: String(result?.error || '成果私信结果未知,请到 B站核对后再决定') }
|
|
2317
|
+
},
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2188
2320
|
tool({
|
|
2189
2321
|
name: 'bili_comments',
|
|
2190
2322
|
description: '取一个视频的热门评论(含点赞/回复数)。刷一轮时顺手翻一眼,看值不值得占用使用者一次注意力。\n'
|
|
@@ -2242,7 +2374,7 @@ export function apply(ctx, config) {
|
|
|
2242
2374
|
+ '把图床链接收进来,起个好叫的名字(如 name:"贴贴");B站默认小表情不用收 —— [doge] 这类码直接写在回复正文里。\n'
|
|
2243
2375
|
+ '⚠️ 内置区(使用者的表情文件夹)只读,collect 时不能盖同名图;满了(60 张)先 forget 再收。\n'
|
|
2244
2376
|
+ 'picture 参数只在 bili_reply **发新评论**时能带(回复楼层不支持图片)。自动采集和自动配图按设置概率实际执行;'
|
|
2245
|
-
+ '你也能主动 collect、补上 description/tags
|
|
2377
|
+
+ '你也能主动 collect、补上 description/tags 并显式选图。开启人工审核时新图先待审,只有使用者在表情收藏页批准后才能用;关闭审核也不会放行旧待审图。自动设置不授予评论权限。',
|
|
2246
2378
|
parameters: {
|
|
2247
2379
|
action: { type: 'string', description: 'list=看库里有什么 · collect=收一张(给 url 或库内 path)· forget=忘掉一张收集区的' },
|
|
2248
2380
|
name: { type: 'string', description: 'collect/forget 用的名字(collect 时起个好叫的,别带路径/扩展名)' },
|
|
@@ -2254,6 +2386,7 @@ export function apply(ctx, config) {
|
|
|
2254
2386
|
rpid: { type: 'string', description: 'collect 来源评论 id(可选,原样字符串)' },
|
|
2255
2387
|
},
|
|
2256
2388
|
async execute(args, exec) {
|
|
2389
|
+
refreshCfg()
|
|
2257
2390
|
const action = String(args?.action ?? 'list').trim() || 'list'
|
|
2258
2391
|
if (action === 'list') {
|
|
2259
2392
|
const all = listStickers(stateDir)
|
|
@@ -2272,15 +2405,18 @@ export function apply(ctx, config) {
|
|
|
2272
2405
|
if (action === 'collect') {
|
|
2273
2406
|
const r = await collectSticker(stateDir, { name: args?.name, url: args?.url, path: args?.path,
|
|
2274
2407
|
description: args?.description, tags: args?.tags, bvid: args?.bvid, rpid: args?.rpid,
|
|
2275
|
-
capacity: cfg.stickerCapacity, signal: exec?.signal
|
|
2408
|
+
capacity: cfg.stickerCapacity, signal: exec?.signal,
|
|
2409
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview } })
|
|
2276
2410
|
if (!r.ok) return { ok: false, error: r.error }
|
|
2277
2411
|
return {
|
|
2278
2412
|
ok: true,
|
|
2279
2413
|
collected: r.name,
|
|
2280
|
-
file: r.file,
|
|
2414
|
+
...(r.status === 'approved' ? { file: r.file } : {}),
|
|
2281
2415
|
bytes: r.bytes,
|
|
2416
|
+
status: r.status,
|
|
2282
2417
|
from: args?.url ? '网络' : '库内拷贝',
|
|
2283
|
-
hint:
|
|
2418
|
+
hint: r.status === 'pending' ? '已放入待审核。请使用者在桌宠设置 → 表情收藏中查看并批准;批准前不能配图。'
|
|
2419
|
+
: '收好了 —— bili_reply({picture:"' + r.name + '"}) 用它。想换图先 forget。',
|
|
2284
2420
|
}
|
|
2285
2421
|
}
|
|
2286
2422
|
if (action === 'forget') {
|
|
@@ -2307,14 +2443,14 @@ export function apply(ctx, config) {
|
|
|
2307
2443
|
+ '说等就等(发新评论 120~180 秒一条、回评论 60~90 秒、每小时各有上限、10 分钟最多 10 次出手)。'
|
|
2308
2444
|
+ '被拦是正常的,换个活干、过会儿再来,别试探性重发(那是 -352 的最短路径)。\n'
|
|
2309
2445
|
+ '🔴 安全闸:桌宠设置「自动化」里的「自动评论视频」**默认是关的**;关着的时候这个工具会拒绝 —— '
|
|
2310
|
-
+ '
|
|
2446
|
+
+ '需要本人在工作台手动打开权限;模型不能用 force 或配置 PATCH 绕过。',
|
|
2311
2447
|
parameters: {
|
|
2312
2448
|
bvid: { type: 'string', description: 'BV号' },
|
|
2313
2449
|
text: { type: 'string', description: '评论正文(短、干净、像真人在说话;别写敏感词)' },
|
|
2314
2450
|
rpid: { type: 'string', description: '要回复的评论 id(0/不填 = 发新评论)' },
|
|
2315
2451
|
root: { type: 'string', description: '你那条主评论的 id(回复楼中楼时填;回复一级评论不用填)' },
|
|
2316
2452
|
picture: { type: 'string', description: '(可选,仅发新评论)表情图名字,最多 2 张、用逗号或空格分隔;bili_sticker({action:"list"}) 查;回复楼层不支持图片' },
|
|
2317
|
-
force: { type: 'boolean', description: '
|
|
2453
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2318
2454
|
},
|
|
2319
2455
|
async execute(args) {
|
|
2320
2456
|
const bvid = String(args.bvid ?? '').trim()
|
|
@@ -2335,7 +2471,9 @@ export function apply(ctx, config) {
|
|
|
2335
2471
|
const fromUrl = (bvid.match(/BV[0-9A-Za-z]{10}/) || [])[0]
|
|
2336
2472
|
if (fromUrl && !looksOpus) {
|
|
2337
2473
|
// 落到下面正常流程(把 bvid 换成抠出来的那个)
|
|
2474
|
+
const reviewed = approvedReviewArgs.get(args)
|
|
2338
2475
|
args = { ...args, bvid: fromUrl }
|
|
2476
|
+
if (reviewed) approvedReviewArgs.set(args, reviewed)
|
|
2339
2477
|
} else {
|
|
2340
2478
|
return {
|
|
2341
2479
|
ok: false,
|
|
@@ -2368,10 +2506,10 @@ export function apply(ctx, config) {
|
|
|
2368
2506
|
ok: false,
|
|
2369
2507
|
blocked: true,
|
|
2370
2508
|
hint: !publicOk
|
|
2371
|
-
? '现在是「只私信」模式(dmMode=only
|
|
2509
|
+
? '现在是「只私信」模式(dmMode=only),不能发公开评论。需要本人在工作台调整投递方式。'
|
|
2372
2510
|
: isReply
|
|
2373
|
-
? '
|
|
2374
|
-
: '
|
|
2511
|
+
? '工作台 → 互动与投递中的「自动回复评论」未开启,请本人手动调整。'
|
|
2512
|
+
: '工作台 → 互动与投递中的「自动评论视频」未开启,请本人手动调整。',
|
|
2375
2513
|
}
|
|
2376
2514
|
}
|
|
2377
2515
|
// 2026-10-20:回复也走判重 —— 回过的别再回(上限取消了,判重是唯一刹车)
|
|
@@ -2428,7 +2566,7 @@ export function apply(ctx, config) {
|
|
|
2428
2566
|
return {
|
|
2429
2567
|
ok: false, blocked: true, shallow: true, template: true, rule: t.rule,
|
|
2430
2568
|
reason: t.reason, marks: t.marks,
|
|
2431
|
-
hint: t.hint + '
|
|
2569
|
+
hint: t.hint + '(最近几条评论可在口味本子里查看;请改写正文,不能用 force 绕过质量检查。)',
|
|
2432
2570
|
}
|
|
2433
2571
|
}
|
|
2434
2572
|
} catch { /* 模板闸自己坏了不能挡住发评论 —— 上面三道质量闸已经过了 */ }
|
|
@@ -2442,6 +2580,7 @@ export function apply(ctx, config) {
|
|
|
2442
2580
|
* · 解析失败**不拦发送**是不对的——图没了话还在,但模型想要的就是那张图,
|
|
2443
2581
|
* 所以这里明确拒绝并说明原因,让它决定是去掉图重发还是先去 collect。 */
|
|
2444
2582
|
let pictures
|
|
2583
|
+
let pictureSnapshots = []
|
|
2445
2584
|
const picInput = String(args.picture ?? '').trim()
|
|
2446
2585
|
if (picInput) {
|
|
2447
2586
|
if (isReply) {
|
|
@@ -2460,31 +2599,54 @@ export function apply(ctx, config) {
|
|
|
2460
2599
|
hint: `一条评论最多带 2 张图(拿到 ${names.length} 个名字)—— 挑最重要的两张,其余留给下一条。`,
|
|
2461
2600
|
}
|
|
2462
2601
|
}
|
|
2463
|
-
const files = []
|
|
2602
|
+
const files = [], snapshots = []
|
|
2464
2603
|
for (const nm of names) {
|
|
2465
|
-
|
|
2466
|
-
|
|
2604
|
+
// Read approved bytes under the library lock and hand the sender a private
|
|
2605
|
+
// snapshot. A forget + same-name pending upload then cannot swap the file
|
|
2606
|
+
// while MCP is still doing its own IO.
|
|
2607
|
+
const shot = snapshotStickerForSend(stateDir, nm)
|
|
2608
|
+
if (!shot) {
|
|
2609
|
+
for (const made of snapshots) made.cleanup()
|
|
2467
2610
|
return {
|
|
2468
2611
|
ok: false, blocked: true, sticker_unknown: true,
|
|
2469
|
-
hint: `表情「${nm.slice(0, 40)}
|
|
2612
|
+
hint: `表情「${nm.slice(0, 40)}」不在已审核通过的库里 —— 用 bili_sticker({action:"list"}) 看现有的,`
|
|
2470
2613
|
+ '或 bili_sticker({action:"collect", url:"…", name:"…"}) 先收进来。本条没发(怕你想要的就是那张图)。',
|
|
2471
2614
|
}
|
|
2472
2615
|
}
|
|
2473
|
-
|
|
2616
|
+
const approved = approvedReviewArgs.get(args)
|
|
2617
|
+
if (approved) {
|
|
2618
|
+
const expected = (approved.attachments || []).find(item => item.name === shot.name)
|
|
2619
|
+
const hash = createHash('sha256').update(readFileSync(shot.file)).digest('hex')
|
|
2620
|
+
if (!expected || expected.hash !== hash) {
|
|
2621
|
+
shot.cleanup()
|
|
2622
|
+
for (const made of snapshots) made.cleanup()
|
|
2623
|
+
return { ok: false, blocked: true, error: '预览后的表情字节已变化,请拒绝旧预览后重新生成', attachmentChanged: true }
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
snapshots.push(shot)
|
|
2627
|
+
files.push(shot.file)
|
|
2474
2628
|
}
|
|
2475
2629
|
pictures = files
|
|
2630
|
+
pictureSnapshots = snapshots
|
|
2476
2631
|
}
|
|
2477
2632
|
|
|
2478
2633
|
/* ⏱️ 出手节奏(2026-10-21 m18802 第 1 条,使用者原话「短时间内连点,几分钟发十几条,
|
|
2479
2634
|
* 系统就可能会判为机器」):发新评论和回别人**分开算**(两种间隔不一样),
|
|
2480
2635
|
* 但连发刹车是**合着算**的 —— 风控看的是"这个号在动",不看动的是哪种。 */
|
|
2481
2636
|
const paceBlocked = paceGate(isReply ? 'reply' : 'comment', Boolean(args.force))
|
|
2482
|
-
if (paceBlocked)
|
|
2637
|
+
if (paceBlocked) {
|
|
2638
|
+
for (const made of pictureSnapshots) made.cleanup()
|
|
2639
|
+
return paceBlocked
|
|
2640
|
+
}
|
|
2483
2641
|
|
|
2484
2642
|
// Only a missing field delegates selection. Explicit picture:"" still means text-only.
|
|
2485
|
-
|
|
2643
|
+
let autoPicture = !isReply && !Object.hasOwn(args, 'picture') && isFishExecution(toolExecution.getStore())
|
|
2486
2644
|
? stickerAutomation.pickForSend(String(args.text ?? '')) : null
|
|
2487
|
-
if (autoPicture)
|
|
2645
|
+
if (autoPicture) {
|
|
2646
|
+
const shot = snapshotStickerForSend(stateDir, autoPicture.name)
|
|
2647
|
+
if (shot) { pictures = [shot.file]; pictureSnapshots = [shot] }
|
|
2648
|
+
else { autoPicture.finish(false); autoPicture = null }
|
|
2649
|
+
}
|
|
2488
2650
|
let out
|
|
2489
2651
|
try {
|
|
2490
2652
|
out = await call('bili_reply', {
|
|
@@ -2494,7 +2656,11 @@ export function apply(ctx, config) {
|
|
|
2494
2656
|
root,
|
|
2495
2657
|
...(pictures ? { pictures } : {}),
|
|
2496
2658
|
})
|
|
2497
|
-
} finally {
|
|
2659
|
+
} finally {
|
|
2660
|
+
autoPicture?.finish(sendOk(out))
|
|
2661
|
+
for (const made of pictureSnapshots) made.cleanup()
|
|
2662
|
+
pictureSnapshots = []
|
|
2663
|
+
}
|
|
2498
2664
|
if (autoPicture && out && typeof out === 'object') out = { ...out, auto_picture: autoPicture.name }
|
|
2499
2665
|
// 发成功才记节奏(失败也记会把后面本来能发的推后)
|
|
2500
2666
|
if (sendOk(out)) await paceDone(isReply ? 'reply' : 'comment')
|
|
@@ -2595,7 +2761,7 @@ export function apply(ctx, config) {
|
|
|
2595
2761
|
tokensTotal: Number(st.tokensTotal) || 0,
|
|
2596
2762
|
totalTokenBudget: Number(cfg.totalTokenBudget) || 0,
|
|
2597
2763
|
dmMode: cfg.dmMode,
|
|
2598
|
-
dmUid:
|
|
2764
|
+
dmUid: cfg.dmUid,
|
|
2599
2765
|
transcribe: cfg.transcribe,
|
|
2600
2766
|
loggedIn: hasCredential(),
|
|
2601
2767
|
home: stateDir,
|
|
@@ -2687,7 +2853,7 @@ export function apply(ctx, config) {
|
|
|
2687
2853
|
+ '适合"我现在就想看一份"的场合。\n'
|
|
2688
2854
|
+ '⚠️ 这是**老的后台刷法**(另起进程、当前对话里看不见)。要"在当前对话里一直刷"用 `bili_feiyu`。',
|
|
2689
2855
|
parameters: {},
|
|
2690
|
-
async execute() { const r = runDuty('manual'); return { ok: true, ...r } },
|
|
2856
|
+
async execute() { const r = runDuty('manual'); return { ok: r.started === true, ...r } },
|
|
2691
2857
|
})
|
|
2692
2858
|
|
|
2693
2859
|
/* ⭐ 鲸鱼摸鱼模式:对话启动 ⇒ 用当前对话模型一直刷(宿主 goal 机制自动续轮) */
|
|
@@ -2716,51 +2882,42 @@ export function apply(ctx, config) {
|
|
|
2716
2882
|
},
|
|
2717
2883
|
})
|
|
2718
2884
|
|
|
2719
|
-
|
|
2720
|
-
* 「每天生成一个视频讲今天的冲浪情况,用鲸鱼摸鱼的虚拟形象讲解,要有声音」。
|
|
2721
|
-
* 它**不发布**(使用者先说本地看),所以这只工具只负责做出来 + 告诉你在哪。 */
|
|
2885
|
+
// The selected conversation model authors the whole film; the renderer executes its validated plan.
|
|
2722
2886
|
tool({
|
|
2723
2887
|
name: 'bili_pv',
|
|
2724
|
-
description: '🎬
|
|
2725
|
-
+ '
|
|
2726
|
-
+ '
|
|
2727
|
-
+ '
|
|
2728
|
-
+ ' 进度用 `status` 看,或看桌宠 ⚙️ 设置 →「视频 · 模型」。日期不填 = 昨天。',
|
|
2888
|
+
description: '🎬 视频工作室:日报 / 观点放大镜 / 精选安利 / 自由创作,成片保存在本地。\n'
|
|
2889
|
+
+ 'make(默认)新开可见制作会话,model 指定负责整条视频的模型;不是只选台词写手。\n'
|
|
2890
|
+
+ '制作会话先 prepare 取约定、分镜规范和当天公开素材,再由当前模型提交 preview 检查节奏,确认后提交 render 的完整 production JSON。\n'
|
|
2891
|
+
+ '渲染异步执行,status 查进度与失败日志;只有 done 且验证通过才交付。list 看成片与制作会话,stop 停本会话的渲染。',
|
|
2729
2892
|
parameters: {
|
|
2730
|
-
action: { type: 'string', enum: ['make', 'status', 'list', 'stop'], description: 'make
|
|
2731
|
-
date: { type: 'string', description: '
|
|
2732
|
-
|
|
2733
|
-
|
|
2893
|
+
action: { type: 'string', enum: ['make', 'session', 'prepare', 'preview', 'render', 'status', 'list', 'stop'], description: 'make/session=新开制作会话 · prepare=取规范和素材 · preview=检查分镜节奏 · render=提交最终计划 · status/list/stop' },
|
|
2894
|
+
date: { type: 'string', description: '素材日期 YYYY-MM-DD,默认本地昨天' },
|
|
2895
|
+
mode: { type: 'string', enum: ['daily', 'rant', 'highlights', 'custom'], description: '冲浪日报 / 观点放大镜 / 精选安利 / 自由创作' },
|
|
2896
|
+
brief: { type: 'string', description: '本条视频的创作要求,最多2000字' },
|
|
2897
|
+
targetDurationSec: { type: 'number', description: '目标时长15–300秒,默认90秒' },
|
|
2898
|
+
model: { type: 'string', description: '负责整条视频的 provider/model;省略用已保存选择或来源会话模型' },
|
|
2899
|
+
production: { type: 'string', description: 'render 必填:符合 prepare 规范的完整 JSON 对象字符串,不含 Markdown 围栏' },
|
|
2734
2900
|
},
|
|
2735
|
-
async execute(args) {
|
|
2901
|
+
async execute(args, exec) {
|
|
2736
2902
|
refreshCfg()
|
|
2903
|
+
const sessionId = String(exec?.agent?.id || '')
|
|
2737
2904
|
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(),
|
|
2905
|
+
try {
|
|
2906
|
+
if (action === 'prepare') return videoStudio.prepare(args, sessionId)
|
|
2907
|
+
if (action === 'preview') return videoStudio.preview(args, sessionId)
|
|
2908
|
+
if (action === 'render') return await videoStudio.render(args, sessionId)
|
|
2909
|
+
if (action === 'status') return { ...videoStudio.status(sessionId), modelId: videoStudio.sessionRow(sessionId)?.model || cfg.videoModelId || '' }
|
|
2910
|
+
if (action === 'list') {
|
|
2911
|
+
const videos = pv.list(), productions = pv.productions()
|
|
2912
|
+
return { ok: true, out: pvDirsOf().out, count: videos.length + productions.length, videos, productions, sessions: videoStudio.list() }
|
|
2757
2913
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2914
|
+
if (action === 'stop') return videoStudio.stop(sessionId)
|
|
2915
|
+
if (action === 'make' || action === 'session') {
|
|
2916
|
+
if (videoStudio.sessionRow(sessionId)) return { ...videoStudio.prepare(args, sessionId), hint: '已在制作会话中;请编排完整计划后调用 render,无需重复创建会话。' }
|
|
2917
|
+
return await videoStudio.start({ ...args, ...(sessionId ? { sourceSessionId: sessionId } : {}) })
|
|
2918
|
+
}
|
|
2919
|
+
return { ok: false, error: `不认识的 action:${action}` }
|
|
2920
|
+
} catch (error) { return { ok: false, error: String(error?.message || error) } }
|
|
2764
2921
|
},
|
|
2765
2922
|
})
|
|
2766
2923
|
|
|
@@ -2768,12 +2925,16 @@ export function apply(ctx, config) {
|
|
|
2768
2925
|
tool({
|
|
2769
2926
|
name: 'bili_moment',
|
|
2770
2927
|
description: '🐟 发一条 **B站动态**(就是"说说"):`text` 是正文,可带图。\n'
|
|
2771
|
-
+ '⚠️
|
|
2772
|
-
+ '一天最多 `momentPerDay` 条(默认 1);正文 300
|
|
2928
|
+
+ '⚠️ 这是拿使用者账号公开发言:只有本人在工作台手动打开 autoMoment 后才能发送;force 不是本人授权。\n'
|
|
2929
|
+
+ '一天最多 `momentPerDay` 条(默认 1);正文 300 字以内,别写风控词、别贴链接。\n'
|
|
2930
|
+
+ '✍️ **动态是自己在开口,不是日报的缩写**:一条只装一个念头,别把今天看了几条、挑中哪条列一遍;'
|
|
2931
|
+
+ '别让「今天看了/读了/挑了/学到…」当默认入口,连着几条同一个起头读的人先看见句式。'
|
|
2932
|
+
+ '换个门进来(一句吐槽、一个还没想通的问题、一个甩不掉的画面、跟视频无关的联想都行),'
|
|
2933
|
+
+ '想提视频就一句带过,长度跟着念头走。**没有想说的就不发,配额不是催办。**',
|
|
2773
2934
|
parameters: {
|
|
2774
2935
|
text: { type: 'string', description: '正文(300 字以内,纯文本)' },
|
|
2775
2936
|
images: { type: 'array', items: { type: 'string' }, description: '(可选)图片地址或本地路径,最多 9 张' },
|
|
2776
|
-
force: { type: 'boolean', description: '
|
|
2937
|
+
force: { type: 'boolean', description: '已停用;true 会被拒绝。模型参数不能代表本人授权,请通过工作台手动设置权限并审核。' },
|
|
2777
2938
|
},
|
|
2778
2939
|
async execute(args) {
|
|
2779
2940
|
refreshCfg()
|
|
@@ -2784,7 +2945,7 @@ export function apply(ctx, config) {
|
|
|
2784
2945
|
if (!gateOn && args?.force !== true) {
|
|
2785
2946
|
return {
|
|
2786
2947
|
ok: false, blocked: true,
|
|
2787
|
-
hint: '
|
|
2948
|
+
hint: '工作台中的「自动发说说」未开启,请本人手动打开。模型不能通过 force 或配置 PATCH 自授权限。',
|
|
2788
2949
|
}
|
|
2789
2950
|
}
|
|
2790
2951
|
// ⚠️ 别用 `Number(...) || 1`:`momentPerDay: 0` 会被吞成 1,而设置页写的是「0 = 不发」。
|
|
@@ -2792,7 +2953,7 @@ export function apply(ctx, config) {
|
|
|
2792
2953
|
const cap = Number.isFinite(capNum) ? Math.max(0, capNum) : 1
|
|
2793
2954
|
const used = countMomentsToday()
|
|
2794
2955
|
if (cap <= 0 && args?.force !== true) {
|
|
2795
|
-
return { ok: false, blocked: true, hint: '
|
|
2956
|
+
return { ok: false, blocked: true, hint: '工作台中的动态每日上限是 0(不发),请本人手动调整;模型不能绕过额度。' }
|
|
2796
2957
|
}
|
|
2797
2958
|
if (used >= cap && args?.force !== true) {
|
|
2798
2959
|
return { ok: false, blocked: true, hint: `今天已经发了 ${used}/${cap} 条说说了,明天再聊。` }
|
|
@@ -2821,7 +2982,8 @@ export function apply(ctx, config) {
|
|
|
2821
2982
|
try { r = await call('bili_send_dynamic', { text, images: Array.isArray(args?.images) ? args.images : [] }) }
|
|
2822
2983
|
catch (error) {
|
|
2823
2984
|
// Cancellation before submission did not spend this reservation.
|
|
2824
|
-
|
|
2985
|
+
const reviewed = approvedReviewExecutions.get(toolExecution.getStore())
|
|
2986
|
+
if (reserved > 0 && ((toolExecution.getStore()?.signal?.aborted && error?.name === 'AbortError') || reviewed && !reviewed.submitted)) await bumpMomentBy(-1)
|
|
2825
2987
|
throw error
|
|
2826
2988
|
}
|
|
2827
2989
|
let ok = false
|
|
@@ -2836,6 +2998,8 @@ export function apply(ctx, config) {
|
|
|
2836
2998
|
}
|
|
2837
2999
|
await paceDone('moment')
|
|
2838
3000
|
const n = reserved > 0 ? reserved : await bumpMoment()
|
|
3001
|
+
// 🪞 记下这次的开口(失败的不记)—— 下一轮实时信息里会把它摆给模型看
|
|
3002
|
+
await rememberMomentOpening(text)
|
|
2839
3003
|
log(`🐟 发了一条说说(今天 ${n}/${cap}):${text.slice(0, 40)}`)
|
|
2840
3004
|
return { ok: true, momentsToday: n, cap, text }
|
|
2841
3005
|
},
|
|
@@ -2848,8 +3012,9 @@ export function apply(ctx, config) {
|
|
|
2848
3012
|
+ '⚠️ 二维码大约 180 秒有效,出码要马上递过去;手机上确认时选「记住我」那种长期登录。',
|
|
2849
3013
|
parameters: {},
|
|
2850
3014
|
async execute() {
|
|
2851
|
-
const { key, url } =
|
|
3015
|
+
const attempt = await loginSession.start(), { key, url } = attempt
|
|
2852
3016
|
const png = await renderQrPng(cfg.pythonPath, stateDir, url)
|
|
3017
|
+
if (!loginSession.current(attempt)) return { ok: false, error: '登录请求已被新的二维码替代,请重新查看最新二维码' }
|
|
2853
3018
|
return {
|
|
2854
3019
|
ok: true, key, url, png,
|
|
2855
3020
|
hint: png ? '用 present 把这张 PNG 推给本人扫;扫完调 bili_login_poll' : '没画出 PNG:先去桌宠 ⚙️ 设置 →「账号」里点「装主依赖」(venv 里要有 qrcode)',
|
|
@@ -2863,9 +3028,9 @@ export function apply(ctx, config) {
|
|
|
2863
3028
|
+ '成功时会自动把凭证写进插件家目录的 `bili_credential.json`(MCP 认的那四个字段)。',
|
|
2864
3029
|
parameters: { key: { type: 'string', description: 'bili_login_start 给的那个 key' } },
|
|
2865
3030
|
async execute(args) {
|
|
2866
|
-
const r = await
|
|
3031
|
+
const r = await loginSession.poll(String(args.key ?? ''))
|
|
2867
3032
|
if (r.state === 'ok') {
|
|
2868
|
-
const file =
|
|
3033
|
+
const file = r.file
|
|
2869
3034
|
log(`登录成功,凭证已写入 ${file}`)
|
|
2870
3035
|
return { ok: true, state: 'ok', file }
|
|
2871
3036
|
}
|
|
@@ -2883,13 +3048,14 @@ export function apply(ctx, config) {
|
|
|
2883
3048
|
|
|
2884
3049
|
tool({
|
|
2885
3050
|
name: 'bili_config',
|
|
2886
|
-
description: '
|
|
2887
|
-
+ '
|
|
2888
|
-
+ '私信投递:`{"dmMode":"only","dmUid":12345678}`(dmMode = off / only / both)。',
|
|
3051
|
+
description: '读取配置或调整内容与陪伴偏好。不给 patch 就是读。模型可改 persona/focus/keywords/reportLevel/perDay/momentStyle/interactionStyle 及闲聊、安静时段等展示偏好。\n'
|
|
3052
|
+
+ '例如:`{"perDay":5,"reportLevel":"brief","keywords":["DeepSeek Harness"]}`。发送权限、收件人、审核、防护、预算、自动任务和执行路径只能由本人在工作台手动设置;模型 PATCH 不能授予权限。',
|
|
2889
3053
|
parameters: { patch: { type: 'object', additionalProperties: true, description: '要改的字段(可省)' } },
|
|
2890
3054
|
async execute(args) {
|
|
2891
3055
|
let host = null
|
|
2892
3056
|
if (args?.patch && typeof args.patch === 'object' && !Array.isArray(args.patch)) {
|
|
3057
|
+
const decision = modelConfigDecision(args.patch)
|
|
3058
|
+
if (!decision.ok) return decision
|
|
2893
3059
|
// ⭐ 设置页显式设过的键以宿主为准 ⇒ 必须跟着写一份,否则"改了没反应"(钳范围也在这里)
|
|
2894
3060
|
host = (await persistCfg(args.patch)).host
|
|
2895
3061
|
}
|
|
@@ -2913,6 +3079,188 @@ export function apply(ctx, config) {
|
|
|
2913
3079
|
return run
|
|
2914
3080
|
}
|
|
2915
3081
|
|
|
3082
|
+
/* ── ✋ 发送前预览队列 ───────────────────────────────────────────────────────
|
|
3083
|
+
* `sendReview` 只对鱼模式的自动发送生效。默认关闭,旧发送语义不变。
|
|
3084
|
+
* 队列落在 state.json,重启后仍能看到待处理内容;批准时重新走原工具,
|
|
3085
|
+
* 进程内 WeakMap 只通过预览排队这一层,原开关、质量、判重、节奏和风控闸都不跳过。 */
|
|
3086
|
+
const SEND_REVIEW_TOOLS = new Set(['bili_reply', 'bili_reply_opus', 'bili_dm_reply', 'bili_moment', 'bili_deliver'])
|
|
3087
|
+
const SEND_REVIEW_MAX = 80
|
|
3088
|
+
const approvedReviewArgs = new WeakMap()
|
|
3089
|
+
const approvedReviewExecutions = new WeakMap()
|
|
3090
|
+
const reviewSending = new Set()
|
|
3091
|
+
const reviewKind = (toolName, args = {}) => {
|
|
3092
|
+
if (toolName === 'bili_dm_reply') return '私信'
|
|
3093
|
+
if (toolName === 'bili_deliver') return '成果私信'
|
|
3094
|
+
if (toolName === 'bili_moment') return '动态'
|
|
3095
|
+
if (toolName === 'bili_reply_opus') return String(args.rpid || '0') === '0' ? '动态评论' : '动态回复'
|
|
3096
|
+
return String(args.rpid || '0') === '0' ? '视频评论' : '视频回复'
|
|
3097
|
+
}
|
|
3098
|
+
const reviewArgs = (args = {}) => {
|
|
3099
|
+
const out = {}
|
|
3100
|
+
for (const key of Object.keys(args || {}).sort()) {
|
|
3101
|
+
const value = args[key]
|
|
3102
|
+
if (key === 'force' || key.startsWith('_')) continue
|
|
3103
|
+
if (typeof value === 'string') out[key] = value
|
|
3104
|
+
else if (Array.isArray(value)) out[key] = value
|
|
3105
|
+
else if (value !== undefined) out[key] = value
|
|
3106
|
+
}
|
|
3107
|
+
return out
|
|
3108
|
+
}
|
|
3109
|
+
const reviewIdOf = (toolName, args, at = Date.now()) => 'sr-' + createHash('sha256').update(JSON.stringify([toolName, reviewArgs(args), at, process.pid])).digest('hex').slice(0, 20)
|
|
3110
|
+
const reviewHash = row => createHash('sha256').update(JSON.stringify([row.tool, reviewArgs(row.args), row.attachments || [], row.targetUid || ''])).digest('hex')
|
|
3111
|
+
const readReviewState = () => {
|
|
3112
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
3113
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) || state.sendReview !== undefined && !Array.isArray(state.sendReview)) throw new Error('发送预览记录损坏,已停止操作以保留原件')
|
|
3114
|
+
return state
|
|
3115
|
+
}
|
|
3116
|
+
const reviewBook = () => {
|
|
3117
|
+
const state = readReviewState()
|
|
3118
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3119
|
+
return rows.filter((row) => row && typeof row === 'object' && row.id && row.args && row.tool)
|
|
3120
|
+
.map(row => row.state === 'approving' && !reviewSending.has(row.id) ? { ...row, state: 'unknown', error: '审批期间运行中断,发送结果未知。请去 B站核对,不能直接重试。' } : row)
|
|
3121
|
+
}
|
|
3122
|
+
const reviewSnapshot = (withItems = true) => {
|
|
3123
|
+
const rows = reviewBook()
|
|
3124
|
+
return {
|
|
3125
|
+
ok: true,
|
|
3126
|
+
enabled: Boolean(cfg.sendReview),
|
|
3127
|
+
rev: createHash('sha256').update(JSON.stringify([Boolean(cfg.sendReview), rows.map(row => [row.id, row.state, row.hash, row.updatedAt])])).digest('hex'),
|
|
3128
|
+
pending: rows.filter((row) => row.state === 'pending').length,
|
|
3129
|
+
sending: rows.filter((row) => row.state === 'approving').length,
|
|
3130
|
+
unknown: rows.filter((row) => row.state === 'unknown').length,
|
|
3131
|
+
counts: Object.fromEntries(['pending', 'approving', 'unknown', 'sent', 'rejected', 'failed'].map(key => [key, rows.filter(row => row.state === key).length])),
|
|
3132
|
+
...(withItems ? { items: rows.slice(-SEND_REVIEW_MAX).reverse().map((row) => ({ ...row, args: reviewArgs(row.args) })) } : {}),
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const queueSendReview = async (toolName, args, exec) => {
|
|
3136
|
+
if (!SEND_REVIEW_TOOLS.has(toolName)) return null
|
|
3137
|
+
refreshCfg()
|
|
3138
|
+
if (!cfg.sendReview || approvedReviewArgs.has(args) || !isFishExecution(exec)) return null
|
|
3139
|
+
const text = typeof args?.text === 'string' ? args.text.trim() : ''
|
|
3140
|
+
if (!text || text.length > (toolName === 'bili_moment' ? 300 : toolName === 'bili_dm_reply' ? 200 : toolName === 'bili_deliver' ? 500 : 1000)) return { ok: false, blocked: true, error: '发送预览需要有效且未超长的正文' }
|
|
3141
|
+
let clean
|
|
3142
|
+
try { clean = validateToolArguments(toolInputSchema(backgroundToolRegistry.get(toolName)?.parameters || {}), reviewArgs(args)) }
|
|
3143
|
+
catch (error) { return { ok: false, blocked: true, error: String(error?.message ?? error) } }
|
|
3144
|
+
if (JSON.stringify(clean).length > 16000) return { ok: false, blocked: true, error: '预览参数过长,未保存内容' }
|
|
3145
|
+
if (toolName === 'bili_reply' && !Object.hasOwn(clean, 'picture')) clean.picture = ''
|
|
3146
|
+
const now = Date.now()
|
|
3147
|
+
const id = reviewIdOf(toolName, clean, now)
|
|
3148
|
+
const attachments = []
|
|
3149
|
+
if (toolName === 'bili_reply' && clean.picture) {
|
|
3150
|
+
const names = String(clean.picture).split(/[,,、]+|\s+/).filter(Boolean)
|
|
3151
|
+
if (names.length > 2) return { ok: false, blocked: true, error: '发送预览最多选择 2 张表情' }
|
|
3152
|
+
for (const name of names) {
|
|
3153
|
+
const shot = snapshotStickerForSend(stateDir, name)
|
|
3154
|
+
if (!shot) return { ok: false, blocked: true, error: '图片未审核或已变化,请重新选择:' + name }
|
|
3155
|
+
try {
|
|
3156
|
+
const hash = createHash('sha256').update(readFileSync(shot.file)).digest('hex')
|
|
3157
|
+
attachments.push({ name: shot.name, hash, imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(shot.name)}&source=${shot.source}&hash=${hash}` })
|
|
3158
|
+
}
|
|
3159
|
+
finally { shot.cleanup() }
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
if (toolName === 'bili_moment' && clean.images?.length) {
|
|
3163
|
+
if (clean.images.length > 9) return { ok: false, blocked: true, error: '动态最多 9 张附图' }
|
|
3164
|
+
for (const name of clean.images) {
|
|
3165
|
+
const shot = snapshotStickerForSend(stateDir, name)
|
|
3166
|
+
if (!shot) return { ok: false, blocked: true, error: '预览动态附图需先收进已审核表情库;不预览随时可能变化的远程 URL:' + name }
|
|
3167
|
+
try {
|
|
3168
|
+
const bytes = readFileSync(shot.file), hash = createHash('sha256').update(bytes).digest('hex')
|
|
3169
|
+
const dir = join(stateDir, 'send-review-media')
|
|
3170
|
+
mkdirSync(dir, { recursive: true })
|
|
3171
|
+
const fmt = sniffImageFormat(bytes)
|
|
3172
|
+
const file = join(dir, hash + '.' + fmt)
|
|
3173
|
+
try { writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 }) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
3174
|
+
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}` })
|
|
3175
|
+
} finally { shot.cleanup() }
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
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 || '') }
|
|
3179
|
+
row.hash = reviewHash(row)
|
|
3180
|
+
const result = await withStateLock(() => {
|
|
3181
|
+
const state = readReviewState()
|
|
3182
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview.filter((item) => item && typeof item === 'object') : []
|
|
3183
|
+
exec?.signal?.throwIfAborted()
|
|
3184
|
+
const duplicate = rows.find((item) => ['pending', 'approving', 'unknown', 'sent'].includes(item.state) && item.hash === row.hash)
|
|
3185
|
+
if (duplicate) return { row: duplicate, duplicate: true }
|
|
3186
|
+
if (rows.filter(item => ['pending', 'approving', 'unknown'].includes(item.state)).length >= SEND_REVIEW_MAX) return { error: '待审队列已满,请先处理现有预览' }
|
|
3187
|
+
rows.push(row)
|
|
3188
|
+
while (rows.length > SEND_REVIEW_MAX) {
|
|
3189
|
+
const removable = rows.findIndex(item => ['sent', 'rejected', 'failed'].includes(item.state))
|
|
3190
|
+
if (removable < 0) break
|
|
3191
|
+
rows.splice(removable, 1)
|
|
3192
|
+
}
|
|
3193
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3194
|
+
return { row, duplicate: false }
|
|
3195
|
+
})
|
|
3196
|
+
if (result?.error) return { ok: false, blocked: true, error: result.error }
|
|
3197
|
+
const saved = result?.row || row
|
|
3198
|
+
act('note', `${result?.duplicate ? '这条' : '新'}${saved.kind}已放进发送预览,等你点头`, { reviewId: saved.id, tool: toolName })
|
|
3199
|
+
return { ok: true, queued: true, blocked: true, reviewId: saved.id, duplicate: Boolean(result?.duplicate), kind: saved.kind,
|
|
3200
|
+
hint: '发送预览已打开:内容还没发出去,去桌宠「互动与投递」的待审核区批准或拒绝。' }
|
|
3201
|
+
}
|
|
3202
|
+
const decideSendReview = async (id, decision, expectedHash) => {
|
|
3203
|
+
const key = String(id || '').trim()
|
|
3204
|
+
if (!/^sr-[a-f0-9]{20}$/.test(key)) return { ok: false, error: 'reviewId 无效' }
|
|
3205
|
+
if (!['approve', 'reject'].includes(decision)) return { ok: false, error: 'decision 只能是 approve / reject' }
|
|
3206
|
+
if (!/^[a-f0-9]{64}$/.test(String(expectedHash || ''))) return { ok: false, error: '请重新查看预览后提交 expectedHash' }
|
|
3207
|
+
let target = null
|
|
3208
|
+
const claimed = await withStateLock(() => {
|
|
3209
|
+
const state = readReviewState()
|
|
3210
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3211
|
+
const index = rows.findIndex((item) => item?.id === key)
|
|
3212
|
+
if (index < 0) return { ok: false, error: '找不到这条待审核内容' }
|
|
3213
|
+
const row = rows[index]
|
|
3214
|
+
if (row.hash !== expectedHash || reviewHash(row) !== expectedHash) return { ok: false, error: '预览内容已变化,请重新查看后决定' }
|
|
3215
|
+
if (row.state !== 'pending') return { ok: false, already: true, item: row }
|
|
3216
|
+
if (decision === 'approve' && (!Number.isFinite(Date.parse(row.createdAt)) || Date.now() - Date.parse(row.createdAt) > 24 * 3600_000)) return { ok: false, error: '这条预览已超过 24 小时,请拒绝旧内容并重新生成,避免发送过时回复' }
|
|
3217
|
+
target = { ...row, args: reviewArgs(row.args) }
|
|
3218
|
+
rows[index] = { ...row, state: decision === 'approve' ? 'approving' : 'rejected', updatedAt: new Date().toISOString(), decidedAt: new Date().toISOString() }
|
|
3219
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3220
|
+
if (decision === 'approve') reviewSending.add(key)
|
|
3221
|
+
return { ok: true }
|
|
3222
|
+
})
|
|
3223
|
+
if (!claimed?.ok) return claimed
|
|
3224
|
+
if (decision === 'reject') {
|
|
3225
|
+
act('note', `已拒绝${target.kind}预览`, { reviewId: key })
|
|
3226
|
+
return { ok: true, item: { ...target, state: 'rejected' } }
|
|
3227
|
+
}
|
|
3228
|
+
const wrapped = backgroundToolRegistry.get(target.tool)
|
|
3229
|
+
let output
|
|
3230
|
+
const approvedArgs = { ...target.args }
|
|
3231
|
+
if (target.tool === 'bili_moment' && target.attachments?.length) {
|
|
3232
|
+
try {
|
|
3233
|
+
approvedArgs.images = target.attachments.map(item => {
|
|
3234
|
+
if (!/^[a-f0-9]{64}$/.test(item.hash) || !['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(item.fmt)) throw new Error('预览附图格式无效,未发送')
|
|
3235
|
+
const file = join(stateDir, 'send-review-media', item.hash + '.' + item.fmt)
|
|
3236
|
+
if (createHash('sha256').update(readFileSync(file)).digest('hex') !== item.hash) throw new Error('预览附图已丢失或变化,未发送')
|
|
3237
|
+
return file
|
|
3238
|
+
})
|
|
3239
|
+
} catch (error) { output = { ok: false, blocked: true, error: String(error?.message ?? error) } }
|
|
3240
|
+
}
|
|
3241
|
+
approvedReviewArgs.set(approvedArgs, target)
|
|
3242
|
+
const reviewed = { row: target, submitted: false }, reviewExec = {}
|
|
3243
|
+
approvedReviewExecutions.set(reviewExec, reviewed)
|
|
3244
|
+
try {
|
|
3245
|
+
if (!output) output = wrapped ? await wrapped.execute(approvedArgs, reviewExec) : { ok: false, blocked: true, error: '发送工具当前不可用,请重启 DSH 后重试' }
|
|
3246
|
+
}
|
|
3247
|
+
catch (error) { output = { ok: false, blocked: !reviewed.submitted, error: String(error?.message ?? error), unknown: reviewed.submitted } }
|
|
3248
|
+
finally { approvedReviewArgs.delete(approvedArgs); approvedReviewExecutions.delete(reviewExec); reviewSending.delete(key) }
|
|
3249
|
+
const sent = Boolean(output && !output.error && !output.blocked && !output.queued && (output.success === true || output.ok === true))
|
|
3250
|
+
const rejected = output?.guard_status === 'rejected' || output?.raw?.guard_status === 'rejected'
|
|
3251
|
+
const unknown = output?.unknown || (!sent && !output?.blocked && reviewed.submitted && !rejected)
|
|
3252
|
+
const stateName = sent ? 'sent' : unknown ? 'unknown' : output?.blocked ? 'pending' : 'failed'
|
|
3253
|
+
await withStateLock(() => {
|
|
3254
|
+
const state = readReviewState()
|
|
3255
|
+
const rows = Array.isArray(state.sendReview) ? state.sendReview : []
|
|
3256
|
+
const index = rows.findIndex((item) => item?.id === key)
|
|
3257
|
+
if (index >= 0) rows[index] = { ...rows[index], state: stateName, result: safe(output), updatedAt: new Date().toISOString(), sentAt: sent ? new Date().toISOString() : '' }
|
|
3258
|
+
writeJson(dutyStateFile, { ...state, sendReview: rows })
|
|
3259
|
+
})
|
|
3260
|
+
act(sent ? 'done' : 'error', `${sent ? '已批准并发送' : '批准后发送失败'}${target.kind}预览`, { reviewId: key })
|
|
3261
|
+
return { ok: sent, sent, item: { ...target, state: stateName, result: safe(output) }, result: safe(output), error: sent ? '' : output?.hint || output?.error || '发送结果未知,请去 B站核对' }
|
|
3262
|
+
}
|
|
3263
|
+
|
|
2916
3264
|
/* ── ⏱️ 出手节奏(2026-10-21 使用者 m18802 第 1、2 条)────────────────────────
|
|
2917
3265
|
* 原话:「发表视频评论不要太频繁,如果短时间内连点,比如几分钟发十几条,系统就可能会判为机器」
|
|
2918
3266
|
* 「所有操作要放慢节奏,不要一次性大量发,要适当间隔一点时间」
|
|
@@ -3107,11 +3455,27 @@ export function apply(ctx, config) {
|
|
|
3107
3455
|
return Math.max(fromState, countPicks(reportFileOf(now)))
|
|
3108
3456
|
}
|
|
3109
3457
|
|
|
3458
|
+
/** 最近七天的事实回顾:只读日报、token 日账和任务明细,不用模型补写主题。 */
|
|
3459
|
+
const sevenDayRetrospective = (st, now = new Date()) => {
|
|
3460
|
+
let ledger = []
|
|
3461
|
+
try { ledger = tokenLedgerSnapshot(stateDir, { limit: 200 }).entries || [] } catch { ledger = [] }
|
|
3462
|
+
return buildSevenDayRetrospective({ now, tokenDays: st?.tokenDays || {}, ledger,
|
|
3463
|
+
readReport: (day) => {
|
|
3464
|
+
const file = join(outDir, `${day}.md`)
|
|
3465
|
+
let size
|
|
3466
|
+
try { size = statSync(file).size } catch (error) { if (error?.code === 'ENOENT') return ''; throw error }
|
|
3467
|
+
if (size > 512 * 1024) throw new Error(`日报超过 512 KiB 安全读取上限(${size} bytes)`)
|
|
3468
|
+
const fd = openSync(file, 'r')
|
|
3469
|
+
try { const bytes = Buffer.alloc(size); const n = readSync(fd, bytes, 0, size, 0); return bytes.subarray(0, n).toString('utf8') }
|
|
3470
|
+
finally { closeSync(fd) }
|
|
3471
|
+
} })
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3110
3474
|
/* ────────── 🗨️ 桌宠「说话」通道(2026-10-28,把已有数据翻译成人话) ──────────
|
|
3111
3475
|
* st.petEvent = { at, text, tone }:收工小结 / 闲聊 / 提醒统一走这里,PetRoot 监听
|
|
3112
3476
|
* .at 变化冒 toast。文案全部本地兜底 —— 收工时不想为一句词再等一次推理。 */
|
|
3113
|
-
const petEvent = (text, tone = 'info') => {
|
|
3114
|
-
try { bumpState({ petEvent: { at: Date.now(), text: String(text || ''), tone } }).catch(() => {}) } catch { /* 说不出来就算 */ }
|
|
3477
|
+
const petEvent = (text, tone = 'info', kind = tone === 'warn' ? 'error' : tone === 'good' ? 'complete' : 'note') => {
|
|
3478
|
+
try { bumpState({ petEvent: { at: Date.now(), text: String(text || ''), tone, kind } }).catch(() => {}) } catch { /* 说不出来就算 */ }
|
|
3115
3479
|
try { act(tone === 'warn' ? 'error' : 'note', text) } catch { /* 流水写不进也不影响收工 */ }
|
|
3116
3480
|
}
|
|
3117
3481
|
const fmtRunLen = (sec) => {
|
|
@@ -3162,15 +3526,14 @@ export function apply(ctx, config) {
|
|
|
3162
3526
|
if (heads.length) {
|
|
3163
3527
|
const t = heads[Math.floor(Math.random() * heads.length)]
|
|
3164
3528
|
picks.push(
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
`今天刷到《${t}》的时候差点笑出声`,
|
|
3529
|
+
`小鱼书签夹在《${t}》这里了,报告里留了选它的理由。`,
|
|
3530
|
+
`想接着翻的话,可以从《${t}》开始;它在今天的精选里。`,
|
|
3168
3531
|
)
|
|
3169
3532
|
}
|
|
3170
3533
|
picks.push(
|
|
3171
3534
|
'鱼今天很闲,水换得勤快,泡泡也比平时圆',
|
|
3172
|
-
'
|
|
3173
|
-
'
|
|
3535
|
+
'鱼的今日难题:是先找有趣的东西,还是先想吃什么。',
|
|
3536
|
+
'给下一点好奇留了个座位,你忙你的,我在这儿。',
|
|
3174
3537
|
)
|
|
3175
3538
|
return picks[Math.floor(Math.random() * picks.length)]
|
|
3176
3539
|
} catch { return '' }
|
|
@@ -3420,6 +3783,7 @@ export function apply(ctx, config) {
|
|
|
3420
3783
|
const learned = learnInject(learnLedger(), { budget: 1200, mode: 'dafeiyu' })
|
|
3421
3784
|
const lines = [
|
|
3422
3785
|
CHARACTER_COMPASS,
|
|
3786
|
+
companionPrompt(cfg),
|
|
3423
3787
|
cfg.persona ? '【使用者为此角色补充的设定】\n' + cfg.persona : '',
|
|
3424
3788
|
/* ⭐ 2026-10-28「刷多久」语义:时长是工作量目标,不是上限。
|
|
3425
3789
|
* 旧版写「做完这一小段就结束进程」,模型刷一小段就自己退了 ⇒ 使用者看到
|
|
@@ -3430,7 +3794,7 @@ export function apply(ctx, config) {
|
|
|
3430
3794
|
? `本次是时段任务:要一直刷到 ${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
3795
|
: Number(opts.durationMinutes) > 0 ? `本次计划刷约 ${opts.durationMinutes} 分钟,` : '本轮刷到节奏账本建议收工为止,')
|
|
3432
3796
|
+ '做完一轮(找片 → 读字幕 → 挑着互动 → 记一句)就接着开下一轮,中间按节奏闸自然休息;时间没到就继续找新视频、翻评论区、整理待审材料,别提前收工。先读章程 ' + duty + '。',
|
|
3433
|
-
buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir }),
|
|
3797
|
+
buildLiveBrief({ cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir, recentOpenings: recentMomentOpenings() }),
|
|
3434
3798
|
'按自己的兴趣找近7天的新视频,读字幕原文后判断;没找到合适的可以空手。可爱、吐槽和闲话都行,不靠固定自称、颜文字或关键词证明人格。',
|
|
3435
3799
|
'开关只代表允许:不要求每挑一条就评、不要求发动态、不要求每轮学习或写心情。发送未知是否成功不能重发;收到节奏或冷却拒绝就换只读的事做。',
|
|
3436
3800
|
'本进程通过宿主工具桥调用同一套鲸鱼摸鱼工具,以实际注册说明为准: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 +3948,7 @@ export function apply(ctx, config) {
|
|
|
3584
3948
|
* 统一收紧:**必须是对象**才算数(bili_reply_opus 一直就是这么判的,口径拉齐)。
|
|
3585
3949
|
*/
|
|
3586
3950
|
const sendOk = (out) => Boolean(out) && typeof out === 'object' && !Array.isArray(out) && !out.error && out.success !== false
|
|
3951
|
+
&& (out.success === true || out.ok === true)
|
|
3587
3952
|
|
|
3588
3953
|
/* ⭐ 跑一轮的互斥 + 生命周期(2026-10-26):
|
|
3589
3954
|
* `runDuty` 有三条入口(bili_run 工具 / 设置页 POST /run / 谷时 tick),
|
|
@@ -4014,12 +4379,12 @@ export function apply(ctx, config) {
|
|
|
4014
4379
|
if (activeManualBrowsing()) return true
|
|
4015
4380
|
if (feiyuAgent) {
|
|
4016
4381
|
const g = ctx.get('goals')?.get(feiyuAgent)
|
|
4017
|
-
if (g &&
|
|
4382
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, feiyuAgent), g).enabled) return true
|
|
4018
4383
|
}
|
|
4019
4384
|
for (const id of feiyuLive.keys()) {
|
|
4020
4385
|
const a = ctx.get('agents')?.get?.(id)
|
|
4021
4386
|
const g = a ? ctx.get('goals')?.get(a) : null
|
|
4022
|
-
if (g &&
|
|
4387
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
|
|
4023
4388
|
}
|
|
4024
4389
|
// ⭐ 2026-10-20:进程重启后 `feiyuLive` 是空的、`feiyuAgent` 是 null,
|
|
4025
4390
|
// 但 goal 是**存在 session 里**的(不随进程消失)⇒ 只靠上面两圈会漏判,
|
|
@@ -4028,7 +4393,7 @@ export function apply(ctx, config) {
|
|
|
4028
4393
|
for (const a of (ctx.get('agents')?.list?.() ?? [])) {
|
|
4029
4394
|
const g = ctx.get('goals')?.get(a)
|
|
4030
4395
|
if (!g || g.phase !== 'active' || g.activation !== 'armed') continue
|
|
4031
|
-
if (
|
|
4396
|
+
if (isFeiyuGoal(g) && personaScope(resolveSessionPreset(ctx, a), g).enabled) return true
|
|
4032
4397
|
}
|
|
4033
4398
|
return false
|
|
4034
4399
|
} catch { return false }
|
|
@@ -4131,13 +4496,13 @@ export function apply(ctx, config) {
|
|
|
4131
4496
|
&& countPickedToday(now) < Number(cfg.perDay || 0)
|
|
4132
4497
|
/* 🫧 闲聊泡泡:空闲时偶尔冒一句观察 —— 下一次开口时间抽好存进 state(不然每跳重抽
|
|
4133
4498
|
* 随机数,等效恒为下限 2.2 小时);免跑牌期间(用户说了「停」)绝不插话。 */
|
|
4134
|
-
if (!scheduleDue && cfg.idleChatter !== false && !(Number(st.bgWindowHold) > now.getTime())) {
|
|
4499
|
+
if (!scheduleDue && cfg.idleChatter !== false && companionPolicy(cfg, now).chatterAllowed && !(Number(st.bgWindowHold) > now.getTime())) {
|
|
4135
4500
|
const due = Number(st.petChatterAt) || 0
|
|
4136
4501
|
if (Date.now() >= due) {
|
|
4137
4502
|
const line = chatterLine()
|
|
4138
4503
|
if (line) {
|
|
4139
|
-
petEvent(line, 'info')
|
|
4140
|
-
bumpState({ petChatterAt:
|
|
4504
|
+
petEvent(line, 'info', 'chatter')
|
|
4505
|
+
bumpState({ petChatterAt: nextChatterAt(cfg) }).catch(() => {})
|
|
4141
4506
|
}
|
|
4142
4507
|
}
|
|
4143
4508
|
}
|
|
@@ -4267,7 +4632,7 @@ export function apply(ctx, config) {
|
|
|
4267
4632
|
const adoptFeiyu = (agent) => {
|
|
4268
4633
|
if (!agent) return null
|
|
4269
4634
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4270
|
-
if (preset.
|
|
4635
|
+
if (preset.id !== 'dafeiyu') return null
|
|
4271
4636
|
if (feiyuLive.has(agent.id)) return feiyuLive.get(agent.id)
|
|
4272
4637
|
let g = null
|
|
4273
4638
|
try { g = ctx.get('goals')?.get(agent) } catch { g = null }
|
|
@@ -4574,6 +4939,7 @@ export function apply(ctx, config) {
|
|
|
4574
4939
|
}
|
|
4575
4940
|
const beginBrowsingTurn = (agent, turn) => {
|
|
4576
4941
|
if (!agent?.id || !agent.session) return
|
|
4942
|
+
if (!isFishExecution({ agent })) return
|
|
4577
4943
|
let previous = browsingTurns.get(agent.id)
|
|
4578
4944
|
if (previous?.session !== agent.session) previous = null
|
|
4579
4945
|
// running is published before turn/start; the latter supplies identity, not
|
|
@@ -4740,6 +5106,32 @@ export function apply(ctx, config) {
|
|
|
4740
5106
|
})
|
|
4741
5107
|
const bumpMoment = () => bumpMomentBy(1)
|
|
4742
5108
|
|
|
5109
|
+
/**
|
|
5110
|
+
* 🪞 记下这条动态是**怎么开口的**,只留开头十几个字,最多留最近 5 条。
|
|
5111
|
+
*
|
|
5112
|
+
* 为什么要记:模型每轮都是新上下文,它**看不见自己上一条长什么样** ——
|
|
5113
|
+
* 光在章程里喊「别老用今天开头」是没用的,得把上一次的开头摆到它眼前。
|
|
5114
|
+
* 真机证据(2026-10-28):最近 12 条动态里 10 条以「今天看了/读了/挑了一条片/学到…」起头。
|
|
5115
|
+
*
|
|
5116
|
+
* ⚠️ 只存开头、不存全文:够它认出句式即可,存全文等于给它一份可抄的旧稿。
|
|
5117
|
+
* ⚠️ 只在**发送成功之后**记(失败/退回名额不该污染这面镜子)。
|
|
5118
|
+
*/
|
|
5119
|
+
const rememberMomentOpening = (text) => withStateLock(() => {
|
|
5120
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim()
|
|
5121
|
+
if (!flat) return
|
|
5122
|
+
const head = [...flat].slice(0, 14).join('')
|
|
5123
|
+
const st = readJson(dutyStateFile, {})
|
|
5124
|
+
const prev = Array.isArray(st.momentOpenings) ? st.momentOpenings : []
|
|
5125
|
+
const next = [...prev, head].slice(-MOMENT_OPENING_KEEP)
|
|
5126
|
+
writeJson(dutyStateFile, { ...st, momentOpenings: next })
|
|
5127
|
+
})
|
|
5128
|
+
|
|
5129
|
+
/** 最近几条动态的开头(给 `buildLiveBrief` 当镜子用;跨天也保留,疲劳是按条算的不是按天) */
|
|
5130
|
+
const recentMomentOpenings = () => {
|
|
5131
|
+
const st = readJson(dutyStateFile, {})
|
|
5132
|
+
return Array.isArray(st.momentOpenings) ? st.momentOpenings.slice(-MOMENT_OPENING_KEEP) : []
|
|
5133
|
+
}
|
|
5134
|
+
|
|
4743
5135
|
/**
|
|
4744
5136
|
* ⭐ 开一轮鲸鱼摸鱼模式:在**当前这个 agent** 上建一个 goal。
|
|
4745
5137
|
* 之后 goal-round-driver 会在每轮结束时自动续上 —— 我们什么都不用做。
|
|
@@ -4747,7 +5139,8 @@ export function apply(ctx, config) {
|
|
|
4747
5139
|
*/
|
|
4748
5140
|
const feiyuStart = (agent, opts = {}) => {
|
|
4749
5141
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4750
|
-
if (preset.
|
|
5142
|
+
if (preset.id !== DAFEIYU_PRESET_ID) return { ok: false, mode_required: true, hint: '先选择鲸鱼摸鱼模式,再明确说开始刷。' }
|
|
5143
|
+
if (!roleTools.refresh(agent)) return { ok: false, scope_unavailable: true, error: '宿主缺少会话工具作用域,已禁用对话内开刷;请更新 DSH。后台与定时设置仍可通过工作台使用。' }
|
|
4751
5144
|
refreshCfg()
|
|
4752
5145
|
const goals = ctx.get('goals')
|
|
4753
5146
|
if (!goals || typeof goals.create !== 'function') {
|
|
@@ -4868,7 +5261,8 @@ export function apply(ctx, config) {
|
|
|
4868
5261
|
|
|
4869
5262
|
const feiyuResume = (agent) => {
|
|
4870
5263
|
const preset = resolveSessionPreset(ctx, agent)
|
|
4871
|
-
if (preset.
|
|
5264
|
+
if (preset.id !== DAFEIYU_PRESET_ID) return { ok: false, mode_required: true, hint: '先切回鲸鱼摸鱼模式,再继续刷。' }
|
|
5265
|
+
if (!roleTools.refresh(agent)) return { ok: false, scope_unavailable: true, error: '宿主缺少会话工具作用域,不能恢复对话内开刷,请更新 DSH。' }
|
|
4872
5266
|
const goals = ctx.get('goals')
|
|
4873
5267
|
const g = (() => { try { return goals.get(agent) } catch { return undefined } })()
|
|
4874
5268
|
if (!g) return { ok: false, error: '这个会话没有 goal,先用 bili_feiyu 开一个' }
|
|
@@ -4947,6 +5341,29 @@ export function apply(ctx, config) {
|
|
|
4947
5341
|
}, 'agent-preset/selected')
|
|
4948
5342
|
} catch (error) { log('鲸鱼摸鱼模式退出监听未挂载:' + String(error?.message ?? error)) }
|
|
4949
5343
|
|
|
5344
|
+
// Preset events may predate plugin loading after restart. The native goal
|
|
5345
|
+
// driver owns its own objective prompt, so an empty persona section alone is
|
|
5346
|
+
// not sufficient: disarm only a stale fish goal before the next request.
|
|
5347
|
+
const enforceModeBoundary = agent => {
|
|
5348
|
+
if (!agent || personaScope(resolveSessionPreset(ctx, agent), null).enabled) return
|
|
5349
|
+
roleTools.detach(agent)
|
|
5350
|
+
cancelStickerSession(agent)
|
|
5351
|
+
finishManualBrowsing(agent, 'mode-exit')
|
|
5352
|
+
const goal = ctx.get('goals')?.get(agent)
|
|
5353
|
+
if (goal?.phase === 'active' && String(goal.objective || '').startsWith(FEIYU_MARK)) {
|
|
5354
|
+
const result = feiyuStop(agent, 'mode-exit')
|
|
5355
|
+
if (!result.ok) log('作用域边界暂停失败:' + String(result.error || 'unknown'))
|
|
5356
|
+
}
|
|
5357
|
+
}
|
|
5358
|
+
try {
|
|
5359
|
+
onEvent('agent/created', ({ agent }) => enforceModeBoundary(agent), '鲸鱼目标重启隔离')
|
|
5360
|
+
onEvent('agent/pre-step', (step, next) => {
|
|
5361
|
+
enforceModeBoundary(step?.agent)
|
|
5362
|
+
return next()
|
|
5363
|
+
}, '鲸鱼目标请求隔离')
|
|
5364
|
+
for (const agent of (ctx.get('agents')?.list?.() || [])) enforceModeBoundary(agent)
|
|
5365
|
+
} catch (error) { log('鲸鱼目标作用域保护未挂载:' + String(error?.message ?? error)) }
|
|
5366
|
+
|
|
4950
5367
|
const feiyuStatus = (agent) => {
|
|
4951
5368
|
refreshCfg()
|
|
4952
5369
|
if (agent) adoptFeiyu(agent) // ⭐ 重启后第一次问状态时把老 goal 认领回来
|
|
@@ -5097,7 +5514,7 @@ export function apply(ctx, config) {
|
|
|
5097
5514
|
} : null)
|
|
5098
5515
|
if (!scope.enabled) return ''
|
|
5099
5516
|
if (!live && agent) live = adoptFeiyu(agent)
|
|
5100
|
-
const parts = [CHARACTER_COMPASS]
|
|
5517
|
+
const parts = [CHARACTER_COMPASS, companionPrompt(cfg), '【当前陪伴档位】' + interactionStyleGuide(cfg.interactionStyle)]
|
|
5101
5518
|
// The early boundary also excludes memories and learning after explicit deselection.
|
|
5102
5519
|
if (cfg.persona && (inFeiyuPreset || reallyRunning)) {
|
|
5103
5520
|
parts.push('===== 你的人格设定(使用者写的,**按这个说话**)=====', cfg.persona, '===== 设定结束 =====')
|
|
@@ -5174,6 +5591,7 @@ export function apply(ctx, config) {
|
|
|
5174
5591
|
if (live && !live.stoppedAt && reallyRunning) {
|
|
5175
5592
|
parts.push(buildLiveBrief({
|
|
5176
5593
|
cfg, day: dayKey(), picked: countPickedToday(), moments: countMomentsToday(), outDir, digestDir,
|
|
5594
|
+
recentOpenings: recentMomentOpenings(),
|
|
5177
5595
|
}))
|
|
5178
5596
|
}
|
|
5179
5597
|
return parts.join('\n')
|
|
@@ -5315,6 +5733,7 @@ export function apply(ctx, config) {
|
|
|
5315
5733
|
description: '🐟 鲸鱼摸鱼模式:开始 / 停止 / 继续 / 状态 / 收台子',
|
|
5316
5734
|
input: { hint: '[start|stop|resume|status|clear]', attachments: false },
|
|
5317
5735
|
handler: ({ agent, rawInput }) => {
|
|
5736
|
+
if (!personaScope(resolveSessionPreset(ctx, agent), null).enabled) return { kind: 'error', text: '此命令仅在鲸鱼摸鱼模式内可用,请先切换模式。' }
|
|
5318
5737
|
const sub = String(rawInput || '').trim().toLowerCase() || 'start'
|
|
5319
5738
|
try {
|
|
5320
5739
|
if (sub === 'stop' || sub === 'pause' || sub === '停') {
|
|
@@ -5432,6 +5851,20 @@ export function apply(ctx, config) {
|
|
|
5432
5851
|
env: pyEnv(),
|
|
5433
5852
|
gsv: pvGsvOf,
|
|
5434
5853
|
})
|
|
5854
|
+
const publicVideoSources = (date) => readRecordEvents(stateDir, { date, type: 'activity', limit: 300, maxBytes: 512 * 1024 })
|
|
5855
|
+
.filter(row => !/(?:bili_dm_|private|inbox)/i.test(String(row.data?.tool || ''))
|
|
5856
|
+
&& !/^(?:dm|private)(?:[.:_-]|$)/i.test(String(row.data?.kind || '')) && !/私信/.test(String(row.data?.text || '')))
|
|
5857
|
+
.flatMap(row => {
|
|
5858
|
+
const data = row.data || {}
|
|
5859
|
+
const bvid = /BV[A-Za-z0-9]{10}/.exec(String(data.bvid || '') + ' ' + String(data.text || ''))?.[0]
|
|
5860
|
+
if (!bvid) return []
|
|
5861
|
+
return [{ id: row.eventId, eventId: row.eventId, date, bvid, kind: 'public-record',
|
|
5862
|
+
title: redactRecordText(data.title || '当日公开视频活动', { maxLength: 160 }),
|
|
5863
|
+
excerpt: redactRecordText(data.text || '', { maxLength: 1000 }),
|
|
5864
|
+
url: `https://www.bilibili.com/video/${bvid}` }]
|
|
5865
|
+
})
|
|
5866
|
+
const videoStudio = createVideoStudio({ ctx, pv, stateDir, config: () => cfg, publicSources: publicVideoSources, log })
|
|
5867
|
+
ctx.effect(() => () => videoStudio.dispose(), 'bili-duty: 收视频制作会话回执')
|
|
5435
5868
|
|
|
5436
5869
|
/**
|
|
5437
5870
|
* 建 venv + 装依赖 —— ⭐ **起完就返回,不等它装完**。
|
|
@@ -5450,13 +5883,7 @@ export function apply(ctx, config) {
|
|
|
5450
5883
|
let uvOk = null
|
|
5451
5884
|
const haveUv = async () => {
|
|
5452
5885
|
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
|
-
})
|
|
5886
|
+
const ok = await probeCmd('uv')
|
|
5460
5887
|
// ⭐ 只缓存"有":把"没有"也缓存住的话,这一轮里刚下好的 uv(或用户刚装上的)会被一直当成没有,
|
|
5461
5888
|
// 非重启 dsh 不可 ✗(调用点只有装依赖那两处,多探一次几毫秒)
|
|
5462
5889
|
if (ok) uvOk = true
|
|
@@ -5493,14 +5920,14 @@ export function apply(ctx, config) {
|
|
|
5493
5920
|
}
|
|
5494
5921
|
|
|
5495
5922
|
/** 探一下某个命令在不在(用于"这台机器有没有 python")—— 陌生人第一条坎就卡在这儿 */
|
|
5496
|
-
const probeCmd = (cmd) => new Promise((resolve) => {
|
|
5923
|
+
const probeCmd = (cmd, args = ['--version']) => new Promise((resolve) => {
|
|
5497
5924
|
let c = null
|
|
5498
5925
|
const finish = makeFinisher(resolve, 20_000, {
|
|
5499
5926
|
value: false,
|
|
5500
5927
|
onTimeout: () => { try { c?.kill('SIGKILL') } catch { /* 已经没了 */ } },
|
|
5501
5928
|
})
|
|
5502
5929
|
try {
|
|
5503
|
-
c = spawn(cmd,
|
|
5930
|
+
c = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
|
|
5504
5931
|
c.on('exit', (code) => finish(code === 0))
|
|
5505
5932
|
c.on('error', () => finish(false))
|
|
5506
5933
|
} catch { finish(false) }
|
|
@@ -5574,13 +6001,24 @@ export function apply(ctx, config) {
|
|
|
5574
6001
|
} catch { /* 轮转失败不该影响跑一轮 */ }
|
|
5575
6002
|
}
|
|
5576
6003
|
let installingAsr = false
|
|
6004
|
+
const asrInstallSnapshot = () => {
|
|
6005
|
+
try {
|
|
6006
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
6007
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('invalid state')
|
|
6008
|
+
const saved = state.asrInstall
|
|
6009
|
+
if (saved === undefined) return { state: 'unknown', phase: '', ok: null, running: false, error: '' }
|
|
6010
|
+
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')
|
|
6011
|
+
if (saved.running && !installingAsr) return { ...saved, state: 'interrupted', ok: false, running: false, error: '上次转写安装已中断,请重新安装转写依赖' }
|
|
6012
|
+
return saved
|
|
6013
|
+
} catch { return { state: 'failed', phase: 'state', ok: false, running: false, error: '转写安装状态损坏,未按就绪处理;请检查本地 state.json' } }
|
|
6014
|
+
}
|
|
6015
|
+
const asrEnvironment = () => inspectAsrEnvironment(asrDir, { installState: asrInstallSnapshot() })
|
|
5577
6016
|
|
|
5578
6017
|
/** 找 uv:系统里的 → 插件家里下好的 → 自己下一份(给"两样都没有"的人);主/转写两套安装共用 */
|
|
5579
6018
|
const resolveUv = async () => {
|
|
5580
6019
|
const bundled = join(stateDir, 'bin', process.platform === 'win32' ? 'uv.exe' : 'uv')
|
|
5581
6020
|
if (await haveUv()) return 'uv'
|
|
5582
|
-
|
|
5583
|
-
appendInstall('这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员、约 15MB,只需要一次)…\n')
|
|
6021
|
+
appendInstall(existsSync(bundled) ? '检查本机缓存 uv 的完整性和可运行状态…\n' : '这台机器上没有 uv —— 我下一份放进插件家目录(单文件、免管理员,只需要一次)…\n')
|
|
5584
6022
|
const got = await ensureUv({ dir: join(stateDir, 'bin'), log: appendInstall })
|
|
5585
6023
|
if (got) appendInstall('uv 已就位:' + got + '\n')
|
|
5586
6024
|
return got
|
|
@@ -5613,15 +6051,24 @@ export function apply(ctx, config) {
|
|
|
5613
6051
|
installingAsr = true
|
|
5614
6052
|
appendAsr(`\n=== ${new Date().toISOString()} 开始装"本地转写"依赖 ===\n`)
|
|
5615
6053
|
;(async () => {
|
|
6054
|
+
let installation = { state: 'running', phase: 'environment', ok: false, running: true, model: String(cfg.asrModel || 'small'), startedAt: new Date().toISOString(), finishedAt: '', error: '' }
|
|
6055
|
+
const transition = async patch => {
|
|
6056
|
+
installation = { ...installation, ...patch }
|
|
6057
|
+
await bumpState({ asrInstall: installation })
|
|
6058
|
+
}
|
|
5616
6059
|
try {
|
|
6060
|
+
await transition({})
|
|
6061
|
+
if (existsSync(asrDir) && lstatSync(asrDir).isSymbolicLink()) throw new Error('转写 .venv-asr 指向符号链接或目录联接;为保护外部环境,本次不安装。请先手动迁移为插件独立环境。')
|
|
5617
6062
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
5618
6063
|
const hf = process.env.BILI_DUTY_HF_ENDPOINT || 'https://hf-mirror.com'
|
|
5619
6064
|
const py = asrPython()
|
|
5620
6065
|
const uvCmd = await resolveUv()
|
|
5621
6066
|
if (!existsSync(py)) {
|
|
5622
6067
|
// --seed:把 pip 也装进去(uv 默认不给)—— 不然出现问题时连 pip 都没有,没法补救 ✗
|
|
5623
|
-
|
|
5624
|
-
|
|
6068
|
+
const created = uvCmd
|
|
6069
|
+
? await spawnTo(uvCmd, ['venv', '--seed', asrDir], { sink: appendAsr })
|
|
6070
|
+
: await spawnTo(String(cfg.pythonPath || 'python'), ['-m', 'venv', asrDir], { sink: appendAsr })
|
|
6071
|
+
if (created !== 0) throw new Error('转写 Python 环境创建失败,请检查上方日志后重试')
|
|
5625
6072
|
}
|
|
5626
6073
|
if (!existsSync(py)) {
|
|
5627
6074
|
// ⭐ 2026-10-19(用户 `天文星球` 的截图):uv 报 `Could not acquire lock … uv\cache\.lock 拒绝访问 (os error 5)`
|
|
@@ -5631,8 +6078,9 @@ export function apply(ctx, config) {
|
|
|
5631
6078
|
appendAsr(' ① 上面日志里出现 "Could not acquire lock / uv\\cache\\.lock / 拒绝访问" ⇒ 是 uv 缓存目录没权限;\n')
|
|
5632
6079
|
appendAsr(' 装新版(0.1.11+)会把缓存改到插件自己的家目录,就不会再碰 %LOCALAPPDATA%\\uv 了。\n')
|
|
5633
6080
|
appendAsr(' ② 机器上没有可用的 uv 或 Python ⇒ 先点上面的「装依赖」,把主依赖装好再来。\n')
|
|
5634
|
-
|
|
6081
|
+
throw new Error('转写:建环境失败')
|
|
5635
6082
|
}
|
|
6083
|
+
await transition({ phase: 'packages' })
|
|
5636
6084
|
appendAsr('装 faster-whisper + yt-dlp(走国内镜像)…\n')
|
|
5637
6085
|
let code = 1
|
|
5638
6086
|
// ⚠️ av 必须钉在 17 以下:faster-whisper 1.2.1 会调 av.open(metadata_errors=…),
|
|
@@ -5640,24 +6088,34 @@ export function apply(ctx, config) {
|
|
|
5640
6088
|
const AV_PIN = process.env.BILI_DUTY_AV_PIN || 'av>=12,<17'
|
|
5641
6089
|
if (uvCmd) code = await spawnTo(uvCmd, ['pip', 'install', '--python', py, '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
5642
6090
|
if (code !== 0) code = await spawnTo(py, ['-m', 'pip', 'install', '-i', mirror, 'faster-whisper', 'yt-dlp', AV_PIN], { sink: appendAsr })
|
|
5643
|
-
|
|
6091
|
+
await transition({ packageExitCode: code })
|
|
6092
|
+
if (code !== 0) throw new Error('转写装包失败,请检查上方日志后重试')
|
|
6093
|
+
await transition({ phase: 'imports' })
|
|
6094
|
+
const importCode = await spawnTo(py, ['-c', ASR_RUNTIME_CHECK], { sink: appendAsr })
|
|
6095
|
+
await transition({ importExitCode: importCode })
|
|
6096
|
+
if (importCode !== 0 || !inspectAsrEnvironment(asrDir).ready) throw new Error('转写运行依赖导入校验失败;不下载模型,也不标记就绪')
|
|
5644
6097
|
// ⭐ 顺手把模型也下好(不然第一次跑那一轮会卡在下载上 ✗);模型走 HuggingFace 国内镜像
|
|
5645
|
-
const model =
|
|
6098
|
+
const model = installation.model
|
|
5646
6099
|
// 模型缓存必须放进插件家:默认的 ~/.cache 在本机拒绝写入(2026-10-14 实测 WinError 5)
|
|
5647
6100
|
const hfHome = join(stateDir, '.hf-cache')
|
|
5648
6101
|
try { mkdirSync(hfHome, { recursive: true }) } catch { /* 建不了后面会给报错 */ }
|
|
5649
6102
|
appendAsr(`下 Whisper 模型「${model}」(缓存在 ${hfHome},源 ${hf},几百 MB,只需要一次)…\n`)
|
|
6103
|
+
await transition({ phase: 'model' })
|
|
5650
6104
|
const code2 = await spawnTo(py, ['-c',
|
|
5651
6105
|
'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
6106
|
model], { sink: appendAsr, env: { HF_ENDPOINT: hf, HF_HOME: hfHome, HF_HUB_DISABLE_XET: '1' } })
|
|
5653
|
-
|
|
6107
|
+
await transition({ modelExitCode: code2 })
|
|
6108
|
+
if (code2 !== 0) throw new Error('转写模型准备失败;不标记就绪,请检查日志后重试')
|
|
6109
|
+
await transition({ state: 'done', phase: 'done', ok: true, running: false, modelReady: true, finishedAt: new Date().toISOString() })
|
|
6110
|
+
appendAsr(`=== 安装校验完成(装包 exit ${code},导入 exit ${importCode},模型 exit ${code2},asrReady=${asrReadyNow()})===\n`)
|
|
5654
6111
|
log(`装转写依赖结束:exit=${code}/${code2}`)
|
|
5655
6112
|
} catch (e) {
|
|
6113
|
+
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
6114
|
appendAsr(`装转写依赖崩了:${e?.stack ?? e}\n`)
|
|
5657
6115
|
log(`装转写依赖崩了:${e?.message ?? e}`)
|
|
5658
6116
|
} finally { installingAsr = false }
|
|
5659
6117
|
})()
|
|
5660
|
-
return { ok: true, started: true, log: asrLogFile, hint: '
|
|
6118
|
+
return { ok: true, started: true, completed: false, log: asrLogFile, hint: '已开始安装转写依赖;运行依赖与模型校验完成后才标记就绪,最终结果和失败原因显示在状态与日志中。' }
|
|
5661
6119
|
}
|
|
5662
6120
|
|
|
5663
6121
|
const runInstall = () => {
|
|
@@ -5665,23 +6123,38 @@ export function apply(ctx, config) {
|
|
|
5665
6123
|
installing = true
|
|
5666
6124
|
appendInstall(`\n=== ${new Date().toISOString()} 开始装依赖 ===\n`)
|
|
5667
6125
|
;(async () => {
|
|
6126
|
+
const venvRoot = join(stateDir, '.venv')
|
|
6127
|
+
let backup = '', keepNewEnvironment = false
|
|
5668
6128
|
try {
|
|
5669
6129
|
const reqFile = join(pluginDir, 'vendor', 'bilibili-mcp', 'requirements.txt')
|
|
5670
6130
|
const mirror = process.env.BILI_DUTY_PIP_MIRROR || 'https://pypi.tuna.tsinghua.edu.cn/simple'
|
|
5671
6131
|
const venvPy = venvPython()
|
|
5672
6132
|
const py = String(cfg.pythonPath || 'python')
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
6133
|
+
if (existsSync(venvRoot) && lstatSync(venvRoot).isSymbolicLink()) throw new Error('插件 .venv 指向符号链接或目录联接;为保护外部环境,本次不安装。请先手动迁移为插件独立环境。')
|
|
6134
|
+
const before = mainEnvironment()
|
|
6135
|
+
const compatibleInterpreter = before.pythonCompatible && await probeCmd(venvPy, ['-c', PYTHON_COMPATIBILITY_CHECK])
|
|
6136
|
+
const needsCreation = !existsSync(venvRoot) || !compatibleInterpreter
|
|
6137
|
+
const uvCmd = await resolveUv()
|
|
6138
|
+
let bootstrapPython = ''
|
|
6139
|
+
if (needsCreation && !uvCmd) {
|
|
6140
|
+
for (const candidate of [...new Set([py, 'python', 'python3'])]) {
|
|
6141
|
+
if (candidate === venvPy) continue
|
|
6142
|
+
if (await probeCmd(candidate, ['-c', PYTHON_COMPATIBILITY_CHECK])) { bootstrapPython = candidate; break }
|
|
6143
|
+
}
|
|
6144
|
+
if (!bootstrapPython) throw new Error('没有可用的 uv 或 Python 3.11–3.13。请安装 uv 或兼容的 Python 后重试;现有环境未改动。')
|
|
5679
6145
|
}
|
|
5680
|
-
if (
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
6146
|
+
if (needsCreation) {
|
|
6147
|
+
if (existsSync(venvRoot)) {
|
|
6148
|
+
const destination = `${venvRoot}.backup-${randomUUID()}`
|
|
6149
|
+
appendInstall(`需要升级 Python(${before.pythonVersion || '版本无法确认'} → 3.11–3.13);先保留旧环境:${destination}\n`)
|
|
6150
|
+
if (mcp) { mcp.stop(); mcp = null; mcpKey = '' }
|
|
6151
|
+
renameSync(venvRoot, destination)
|
|
6152
|
+
backup = destination
|
|
6153
|
+
}
|
|
6154
|
+
const created = uvCmd
|
|
6155
|
+
? await spawnLogged(uvCmd, ['venv', '--python', MAIN_PYTHON_RANGE, '--seed', venvRoot])
|
|
6156
|
+
: await spawnLogged(bootstrapPython, ['-m', 'venv', venvRoot])
|
|
6157
|
+
if (created !== 0 || !existsSync(venvPy) || !(await probeCmd(venvPy, ['-c', PYTHON_COMPATIBILITY_CHECK]))) throw new Error('未能创建 Python 3.11–3.13 环境;未继续安装依赖。')
|
|
5685
6158
|
}
|
|
5686
6159
|
let code = 1
|
|
5687
6160
|
// ⭐ 2026-10-17(issue #8②):镜像也会整站拒绝(清华对部分 IP 回 403,而 pip 的报错写成"这个包不存在",很误导)
|
|
@@ -5690,16 +6163,21 @@ export function apply(ctx, config) {
|
|
|
5690
6163
|
for (let i = 0; i < mirrors.length && code !== 0; i++) {
|
|
5691
6164
|
const m = mirrors[i]
|
|
5692
6165
|
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])
|
|
6166
|
+
if (uvCmd) code = await spawnLogged(uvCmd, ['pip', 'install', '--require-hashes', '--python', venvPy, '-i', m, '-r', reqFile])
|
|
6167
|
+
if (code !== 0) code = await spawnLogged(venvPy, ['-m', 'pip', 'install', '--require-hashes', '-i', m, '-r', reqFile])
|
|
5695
6168
|
}
|
|
5696
|
-
|
|
6169
|
+
const candidate = inspectMainEnvironment(venvRoot, { ignoreRuntimeFailure: true })
|
|
6170
|
+
const runtimeOk = code === 0 && candidate.ready && await probeCmd(venvPy, ['-c', MAIN_RUNTIME_CHECK])
|
|
6171
|
+
if (candidate.exists) atomicWriteFileSync(join(venvRoot, 'deepmoyu-health.json'), JSON.stringify({ ok: runtimeOk, at: new Date().toISOString() }))
|
|
6172
|
+
if (runtimeOk) {
|
|
5697
6173
|
// ⚠️ 光写 config.json 不够:DSH 原生设置里那份旧值("python")会在 loadCfg 时压回来
|
|
5698
6174
|
// (已由 loadCfg 的 HOST_WINS_EXCLUDED 解决 ✓);这里再把宿主的同步结果说清楚,
|
|
5699
6175
|
// 免得以后再出现"日志说设好了、实际没生效" ✗
|
|
5700
6176
|
const r = await persistCfg({ pythonPath: venvPy })
|
|
6177
|
+
keepNewEnvironment = true
|
|
5701
6178
|
appendInstall(`已把 pythonPath 设为 ${venvPy}(DSH 那边${r?.host?.ok ? '已同步' : '没写成:' + (r?.host?.error || '未知原因')})\n`)
|
|
5702
|
-
|
|
6179
|
+
if (backup) appendInstall(`升级完成;旧环境保留在 ${backup},未自动删除。\n`)
|
|
6180
|
+
} else throw new Error(`主环境安装或运行校验失败(exit ${code}):${mainEnvironment().upgradeHint || 'Python 或 Pillow 实际导入未通过'};不会标记为就绪。`)
|
|
5703
6181
|
{
|
|
5704
6182
|
const miss = missingVenvDeps()
|
|
5705
6183
|
appendInstall(`=== 结束(exit ${code},venvReady=${venvReadyNow()}${miss.length ? ',缺:' + miss.join(' / ') : ''})===\n`)
|
|
@@ -5708,9 +6186,23 @@ export function apply(ctx, config) {
|
|
|
5708
6186
|
} catch (e) {
|
|
5709
6187
|
appendInstall(`装依赖崩了:${e?.stack ?? e}\n`)
|
|
5710
6188
|
log(`装依赖崩了:${e?.message ?? e}`)
|
|
5711
|
-
} finally {
|
|
6189
|
+
} finally {
|
|
6190
|
+
if (backup && !keepNewEnvironment && existsSync(backup)) {
|
|
6191
|
+
try {
|
|
6192
|
+
if (existsSync(venvRoot)) {
|
|
6193
|
+
const failed = `${venvRoot}.failed-${randomUUID()}`
|
|
6194
|
+
renameSync(venvRoot, failed)
|
|
6195
|
+
appendInstall(`未完成的新环境保留在 ${failed}\n`)
|
|
6196
|
+
}
|
|
6197
|
+
renameSync(backup, venvRoot)
|
|
6198
|
+
appendInstall('本次升级未完成,已恢复原环境;仍需升级 Python / Pillow,未报告就绪。\n')
|
|
6199
|
+
} catch (error) { appendInstall(`自动恢复失败;原环境仍保留在 ${backup},请手动恢复:${error?.message || error}\n`) }
|
|
6200
|
+
}
|
|
6201
|
+
installing = false
|
|
6202
|
+
}
|
|
5712
6203
|
})()
|
|
5713
|
-
return { ok: true, started: true, log: installLogFile,
|
|
6204
|
+
return { ok: true, started: true, log: installLogFile, pythonRange: MAIN_PYTHON_RANGE, pillowMinimum: '13.0',
|
|
6205
|
+
upgradeRequired: mainEnvironment().upgradeRequired, hint: '正在安装并校验 Python 3.11–3.13 与 Pillow 13.0+;旧 Python 环境升级前会保留备份,进度显示在下面。' }
|
|
5714
6206
|
}
|
|
5715
6207
|
|
|
5716
6208
|
/** 「测一下」:真跑一次查询再下结论(判据落在载荷上,不看自报) */
|
|
@@ -5780,6 +6272,16 @@ export function apply(ctx, config) {
|
|
|
5780
6272
|
} catch { return '' }
|
|
5781
6273
|
}
|
|
5782
6274
|
|
|
6275
|
+
const recoverySnapshot = ({ feiyu, schedule, state }) => {
|
|
6276
|
+
let videoRows = []
|
|
6277
|
+
try { videoRows = videoStudio.list() } catch { videoRows = [] }
|
|
6278
|
+
let lastBackground = null
|
|
6279
|
+
try { lastBackground = tokenLedgerSnapshot(stateDir, { limit: 40 }).entries.find((row) => row?.kind === 'background' && row?.endedAt) || null } catch { lastBackground = null }
|
|
6280
|
+
return buildRecoverySnapshot({ now: Date.now(), feiyu, background: backgroundRun, backgroundAlive: Boolean(dutyChild), lastBackground, schedule,
|
|
6281
|
+
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 },
|
|
6282
|
+
videoSessions: videoRows, lastRunAt: state?.lastRunAt || null })
|
|
6283
|
+
}
|
|
6284
|
+
|
|
5783
6285
|
/**
|
|
5784
6286
|
* ⭐ /status 增量短路(2026-10-27):前端 5 秒一问,99% 的时候什么都没变。
|
|
5785
6287
|
* statusRev() 只用**便宜信号**拼一个签名(一次 state.json + 几个 statSync + 内存扫描),
|
|
@@ -5804,7 +6306,10 @@ export function apply(ctx, config) {
|
|
|
5804
6306
|
JSON.stringify(st.pace || {}).length, JSON.stringify(st.seenVideos || {}).length,
|
|
5805
6307
|
JSON.stringify(st.repliedComments || {}).length, Number(st.lastRunAt) || 0)
|
|
5806
6308
|
bits.push(JSON.stringify(st.tokenDays || {}), Number(st.activeDays) || 0)
|
|
6309
|
+
bits.push(JSON.stringify(st.asrInstall || {}))
|
|
6310
|
+
bits.push(JSON.stringify((Array.isArray(st.sendReview) ? st.sendReview : []).map(row => [row?.id, row?.state, row?.hash, row?.updatedAt])))
|
|
5807
6311
|
bits.push(Number(st.bgWindowHold) || 0, Number(st.petEvent && st.petEvent.at) || 0)
|
|
6312
|
+
bits.push(String(st.tasteAt || ''), JSON.stringify(st.taste || {}))
|
|
5808
6313
|
/* ⏱️ 时钟派生字段的通解(2026-10-28 审查补漏):bgWindow.active / schedule 的
|
|
5809
6314
|
* due·nextRunAt / pace 倒计时 / attention 额度阈值都随墙钟走而账面不变 ——
|
|
5810
6315
|
* 分钟桶让 rev 每分钟翻一次,UI 的倒计时不再冻结。 */
|
|
@@ -5833,9 +6338,12 @@ export function apply(ctx, config) {
|
|
|
5833
6338
|
for (const file of [installLogFile, asrLogFile, join(stateDir, SOUL_FILE), join(stateDir, 'bili_credential.json')]) {
|
|
5834
6339
|
try { const s = statSync(file); bits.push(s.size, s.mtimeMs) } catch { bits.push(0) }
|
|
5835
6340
|
}
|
|
6341
|
+
try { const s = statSync(join(stateDir, 'pv-sessions.json')); bits.push(s.size, s.mtimeMs) } catch { bits.push(0) }
|
|
6342
|
+
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
6343
|
try { bits.push(learnStats(asBook(snapshot.state.learn)).pending) } catch { bits.push('learn!') }
|
|
5837
6344
|
try { bits.push(String(snapshot.mirrorManifest?.at || '')) } catch { bits.push('mirror!') }
|
|
5838
6345
|
bits.push(modelCatalog.length, myVersion(), installing ? 1 : 0, installingAsr ? 1 : 0)
|
|
6346
|
+
bits.push(JSON.stringify(roleTools.snapshot()))
|
|
5839
6347
|
// All previous inputs remain covered without echoing the full config/ledger into the polling URL.
|
|
5840
6348
|
return createHash('sha256').update(JSON.stringify(bits)).digest('hex')
|
|
5841
6349
|
}
|
|
@@ -5847,6 +6355,11 @@ export function apply(ctx, config) {
|
|
|
5847
6355
|
// ⭐ findFeiyuAgent 要扫全部 agent,一次 status 只扫一遍(以前 feiyu/modelNow/tokenUsage 各扫一次)
|
|
5848
6356
|
let feiyuAgentNow = null
|
|
5849
6357
|
try { feiyuAgentNow = findFeiyuAgent() } catch { feiyuAgentNow = null }
|
|
6358
|
+
let feiyuNow
|
|
6359
|
+
try { feiyuNow = feiyuAgentNow ? feiyuStatus(feiyuAgentNow) : { ok: true, active: false, model: currentModel() } }
|
|
6360
|
+
catch { feiyuNow = { ok: true, active: false, model: '' } }
|
|
6361
|
+
const scheduleNow = scheduleSnapshot()
|
|
6362
|
+
const asrNow = asrEnvironment()
|
|
5850
6363
|
return {
|
|
5851
6364
|
ok: true,
|
|
5852
6365
|
home: stateDir,
|
|
@@ -5859,7 +6372,9 @@ export function apply(ctx, config) {
|
|
|
5859
6372
|
lastWhy: st.lastWhy || null,
|
|
5860
6373
|
loggedIn: hasCredential(),
|
|
5861
6374
|
venvReady: venvReadyNow(),
|
|
6375
|
+
environment: mainEnvironment(),
|
|
5862
6376
|
version: myVersion(),
|
|
6377
|
+
toolScope: roleTools.snapshot(),
|
|
5863
6378
|
// 2026-10-18:这几项"本地存了、但没能同步到 DSH 原生设置" ⇒ 设置页要告诉用户
|
|
5864
6379
|
// (DSH 0.2.1 某些启动方式会拒写:profile reload requires the root Include entry)
|
|
5865
6380
|
notSynced: Object.keys(readOverride()),
|
|
@@ -5869,9 +6384,12 @@ export function apply(ctx, config) {
|
|
|
5869
6384
|
installFailed: !installing && !venvReadyNow() && (() => { try { return statSync(installLogFile).size > 0 } catch { return false } })(),
|
|
5870
6385
|
installing,
|
|
5871
6386
|
// ── 本地转写那一套的状态(设置页要显示进度)──
|
|
5872
|
-
asrReady:
|
|
6387
|
+
asrReady: asrNow.ready,
|
|
6388
|
+
asrEnvironment: asrNow,
|
|
6389
|
+
asrInstall: asrNow.install,
|
|
5873
6390
|
// 模型下没下(免得界面写"就绪"、其实还得现下)
|
|
5874
|
-
asrModelReady:
|
|
6391
|
+
asrModelReady: existsSync(join(stateDir, '.hf-cache', 'hub')) && (asrNow.install.state === 'unknown'
|
|
6392
|
+
|| asrNow.install.ok === true && asrNow.install.modelReady === true && asrNow.install.model === String(cfg.asrModel || 'small')),
|
|
5875
6393
|
asrInstalling: installingAsr,
|
|
5876
6394
|
asrLog: asrLogFile,
|
|
5877
6395
|
asrTail: tailText(asrLogFile),
|
|
@@ -5889,12 +6407,16 @@ export function apply(ctx, config) {
|
|
|
5889
6407
|
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
6408
|
/* 🗨️🎨 显示层:petEvent 是桌宠「说话」的通道(收工小结/闲聊),mood 是心情色,
|
|
5891
6409
|
* 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,
|
|
6410
|
+
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,
|
|
6411
|
+
companion: companionPolicy(cfg),
|
|
5893
6412
|
mood: moodOf(),
|
|
5894
6413
|
attention: (() => {
|
|
5895
6414
|
const reasons = []
|
|
5896
6415
|
try {
|
|
5897
6416
|
if (!venvReadyNow()) reasons.push('主依赖还没装好 —— 「账号与环境」页点「装主依赖」')
|
|
6417
|
+
const previews = reviewSnapshot(false)
|
|
6418
|
+
if (previews.unknown > 0) reasons.push(`有 ${previews.unknown} 条发送结果需要核对 —— 「互动与投递」查看,别直接重发`)
|
|
6419
|
+
else if (previews.pending > 0) reasons.push(`有 ${previews.pending} 条内容等你预览 —— 「互动与投递」逐条批准或拒绝`)
|
|
5898
6420
|
const pending = Number(learnStats(learnLedger()).pending) || 0
|
|
5899
6421
|
if (pending >= 5) reasons.push(`学习队列积了 ${pending} 条待审 —— 顺手去「学习与记忆」翻一翻`)
|
|
5900
6422
|
const book = st.pace
|
|
@@ -5903,19 +6425,18 @@ export function apply(ctx, config) {
|
|
|
5903
6425
|
} catch { /* 读不到就不提醒,别让提醒本身变成故障 */ }
|
|
5904
6426
|
return { level: reasons.length ? 'soft' : 'none', reasons }
|
|
5905
6427
|
})(),
|
|
5906
|
-
schedule:
|
|
6428
|
+
schedule: scheduleNow,
|
|
5907
6429
|
learnReview: learningReviewStatus(),
|
|
6430
|
+
sendReview: (() => { try { return reviewSnapshot(false) } catch (error) { return { ok: false, enabled: Boolean(cfg.sendReview), error: String(error?.message ?? error), pending: null } } })(),
|
|
6431
|
+
retrospective: sevenDayRetrospective(st),
|
|
6432
|
+
recovery: recoverySnapshot({ feiyu: feiyuNow, schedule: scheduleNow, state: st }),
|
|
5908
6433
|
petShow: Boolean(cfg.petShow),
|
|
5909
6434
|
// ── 🐟 鲸鱼摸鱼模式(设置页要显示"现在是不是在刷")──
|
|
5910
6435
|
// ⭐ 用 findFeiyuAgent() 而不是裸 `feiyuAgent`:进程重启后这个变量是 null,
|
|
5911
6436
|
// 只认它会让设置页在真的还在刷的时候谎报「没在刷」(那个 goal 是活着的)。
|
|
5912
6437
|
// ⚠️ currentModel / tokensOf 都要**把 agent 传进去**:不传只能拿到全局默认模型,
|
|
5913
6438
|
// 界面上那个「当前对话在用的模型」就会是假的。
|
|
5914
|
-
feiyu:
|
|
5915
|
-
try {
|
|
5916
|
-
return feiyuAgentNow ? feiyuStatus(feiyuAgentNow) : { ok: true, active: false, model: currentModel() }
|
|
5917
|
-
} catch { return { ok: true, active: false, model: '' } }
|
|
5918
|
-
})(),
|
|
6439
|
+
feiyu: feiyuNow,
|
|
5919
6440
|
modelNow: currentModel(feiyuAgentNow || undefined),
|
|
5920
6441
|
// ⭐「指定已有模型」用:宿主手上真有的那些模型(取不到就是空数组,前端退回手填)
|
|
5921
6442
|
models: listModels(),
|
|
@@ -5992,7 +6513,8 @@ export function apply(ctx, config) {
|
|
|
5992
6513
|
* 「现在能不能发、还要等多久、风控冷却到几点」,不然使用者只会觉得"它卡住了"。 */
|
|
5993
6514
|
pace: (() => {
|
|
5994
6515
|
try {
|
|
5995
|
-
|
|
6516
|
+
const factor = paceFactor(), now = Date.now(), book = st.pace
|
|
6517
|
+
return { ok: true, factor, ...paceSummary(book, now, factor), explanation: paceExplain(book, now, factor) }
|
|
5996
6518
|
} catch (e) { return { ok: false, error: String(e?.message ?? e) } }
|
|
5997
6519
|
})(),
|
|
5998
6520
|
/* 🪞 她自己的账(2026-10-21 使用者第 3 条「更有人情味、能不断成长」)——
|
|
@@ -6001,6 +6523,26 @@ export function apply(ctx, config) {
|
|
|
6001
6523
|
soul: (() => {
|
|
6002
6524
|
try { return soulSnapshot() } catch (e) { return { ok: false, empty: true, text: '', error: String(e?.message ?? e) } }
|
|
6003
6525
|
})(),
|
|
6526
|
+
/* 🧠 记忆中心:把学习、心情、口味和判重账本放进同一份只读快照。
|
|
6527
|
+
* 文案由客户端组合,服务端只暴露经过 lib/taste.mjs 脱敏和阈值过滤的统计,
|
|
6528
|
+
* 不把整本 taste/state 原样塞进状态轮询。 */
|
|
6529
|
+
taste: (() => {
|
|
6530
|
+
try {
|
|
6531
|
+
const ledger = st.taste && typeof st.taste === 'object' ? st.taste : {}
|
|
6532
|
+
const stats = tasteStats(ledger)
|
|
6533
|
+
const digest = tasteDigest(ledger)
|
|
6534
|
+
return {
|
|
6535
|
+
sample: Number(stats.sample) || 0,
|
|
6536
|
+
checked: Number(stats.checked) || 0,
|
|
6537
|
+
minN: Number(stats.minN) || TASTE_MIN_N,
|
|
6538
|
+
enough: Boolean(digest.enough),
|
|
6539
|
+
lines: Array.isArray(digest.lines) ? digest.lines.slice(0, 4) : [],
|
|
6540
|
+
rows: Array.isArray(stats.rows) ? stats.rows.slice(0, 8) : [],
|
|
6541
|
+
top: tasteTop(ledger, 3),
|
|
6542
|
+
updatedAt: String(st.tasteAt || ''),
|
|
6543
|
+
}
|
|
6544
|
+
} catch (e) { return { sample: 0, checked: 0, minN: TASTE_MIN_N, enough: false, lines: [], rows: [], top: [], updatedAt: '', error: String(e?.message ?? e) } }
|
|
6545
|
+
})(),
|
|
6004
6546
|
}
|
|
6005
6547
|
}
|
|
6006
6548
|
|
|
@@ -6011,11 +6553,14 @@ export function apply(ctx, config) {
|
|
|
6011
6553
|
try {
|
|
6012
6554
|
const origin = String(req.headers?.origin || '')
|
|
6013
6555
|
if (origin) {
|
|
6014
|
-
let host = ''
|
|
6015
|
-
try {
|
|
6556
|
+
let host = '', parsed = null
|
|
6557
|
+
try { parsed = new URL(origin); host = parsed.hostname } catch { host = '' }
|
|
6016
6558
|
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1' && host !== '[::1]') {
|
|
6017
6559
|
return sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
|
|
6018
6560
|
}
|
|
6561
|
+
// Another localhost port is another application's origin. Browser
|
|
6562
|
+
// requests carry Host; legacy in-process adapters may omit it.
|
|
6563
|
+
if (req.headers?.host && parsed?.host.toLowerCase() !== String(req.headers.host).toLowerCase()) return sendJson(res, 403, { ok: false, error: 'cross-origin request rejected' })
|
|
6019
6564
|
}
|
|
6020
6565
|
} catch { /* 判不了就按老样子继续 */ }
|
|
6021
6566
|
refreshCfg()
|
|
@@ -6030,15 +6575,44 @@ export function apply(ctx, config) {
|
|
|
6030
6575
|
return sendJson(res, 200, { ...status(snapshot), rev })
|
|
6031
6576
|
}
|
|
6032
6577
|
|
|
6578
|
+
if (req.method === 'GET' && path === '/api/bili-duty/send-review/image') {
|
|
6579
|
+
const hash = String(url.searchParams.get('hash') || '')
|
|
6580
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) return sendJson(res, 404, { ok: false, error: 'unknown preview image' })
|
|
6581
|
+
const item = reviewBook().flatMap(row => row.attachments || []).find(image => image.kind === 'dynamic' && image.hash === hash)
|
|
6582
|
+
if (!item) return sendJson(res, 404, { ok: false, error: 'unknown preview image' })
|
|
6583
|
+
try {
|
|
6584
|
+
if (!['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(item.fmt)) return sendJson(res, 404, { ok: false, error: 'preview image format invalid' })
|
|
6585
|
+
const bytes = readFileSync(join(stateDir, 'send-review-media', hash + '.' + item.fmt))
|
|
6586
|
+
if (createHash('sha256').update(bytes).digest('hex') !== hash) return sendJson(res, 404, { ok: false, error: 'preview image changed' })
|
|
6587
|
+
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
|
|
6588
|
+
} catch { return sendJson(res, 404, { ok: false, error: 'preview image missing' }) }
|
|
6589
|
+
}
|
|
6590
|
+
if (path === '/api/bili-duty/send-review') {
|
|
6591
|
+
if (req.method === 'GET') return sendJson(res, 200, reviewSnapshot())
|
|
6592
|
+
if (req.method === 'POST') {
|
|
6593
|
+
let body
|
|
6594
|
+
try { body = await readBody(req, { strict: true }) }
|
|
6595
|
+
catch (error) { return sendJson(res, error.status || 400, { ok: false, code: error.code || 'INVALID_JSON', error: error.message }) }
|
|
6596
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return sendJson(res, 400, { ok: false, error: '请求必须是对象' })
|
|
6597
|
+
const result = await decideSendReview(body.id, String(body.decision || ''), body.expectedHash)
|
|
6598
|
+
return sendJson(res, result.ok ? 200 : result.already ? 409 : 400, { ...reviewSnapshot(), ...result })
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
|
|
6033
6602
|
if (req.method === 'GET' && path === '/api/bili-duty/stickers') {
|
|
6034
|
-
|
|
6603
|
+
const status = url.searchParams.get('status') || 'approved'
|
|
6604
|
+
if (!['approved', 'pending', 'all'].includes(status)) return sendJson(res, 400, { ok: false, error: 'status 只认 approved / pending / all' })
|
|
6605
|
+
try { return sendJson(res, 200, stickerGallery(status)) }
|
|
6035
6606
|
catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6036
6607
|
}
|
|
6037
6608
|
if (req.method === 'GET' && path === '/api/bili-duty/stickers/image') {
|
|
6038
6609
|
try {
|
|
6039
|
-
const
|
|
6040
|
-
if (!
|
|
6041
|
-
const
|
|
6610
|
+
const status = url.searchParams.get('status') || 'approved'
|
|
6611
|
+
if (!['approved', 'pending', 'all'].includes(status)) return sendJson(res, 404, { ok: false, error: 'unknown sticker' })
|
|
6612
|
+
const image = readStickerImage(stateDir, url.searchParams.get('name'), url.searchParams.get('source') || undefined, { status })
|
|
6613
|
+
const hash = url.searchParams.get('hash')
|
|
6614
|
+
if (!image || (hash && image.etag !== '"' + hash + '"')) return sendJson(res, 404, { ok: false, error: 'unknown sticker' })
|
|
6615
|
+
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
6616
|
if (req.headers?.['if-none-match'] === image.etag) { res.writeHead(304, headers); res.end(); return }
|
|
6043
6617
|
res.writeHead(200, { ...headers, 'content-length': image.bytes.length })
|
|
6044
6618
|
res.end(image.bytes)
|
|
@@ -6049,10 +6623,12 @@ export function apply(ctx, config) {
|
|
|
6049
6623
|
const body = await readBody(req)
|
|
6050
6624
|
// The UI accepts public image URLs; library copying remains the private tool's existing capability.
|
|
6051
6625
|
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
|
|
6626
|
+
description: body?.description, tags: body?.tags, bvid: body?.bvid, rpid: body?.rpid, capacity: cfg.stickerCapacity,
|
|
6627
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview } })
|
|
6053
6628
|
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`
|
|
6629
|
+
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, sourceUrl: result.sourceUrl, status: result.status,
|
|
6630
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6631
|
+
+ (result.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(result.hash)}` : '') })
|
|
6056
6632
|
}
|
|
6057
6633
|
if (req.method === 'POST' && path === '/api/bili-duty/stickers/upload') {
|
|
6058
6634
|
let body
|
|
@@ -6061,6 +6637,7 @@ export function apply(ctx, config) {
|
|
|
6061
6637
|
const result = uploadSticker(stateDir, body && typeof body === 'object' && !Array.isArray(body) ? {
|
|
6062
6638
|
name: body.name, filename: body.filename, dataBase64: body.dataBase64,
|
|
6063
6639
|
description: body.description, tags: body.tags, capacity: cfg.stickerCapacity,
|
|
6640
|
+
get stickerReview() { refreshCfg(); return cfg.stickerReview },
|
|
6064
6641
|
} : null)
|
|
6065
6642
|
if (!result.ok) {
|
|
6066
6643
|
const status = result.code === 'STICKER_TOO_LARGE' ? 413 : result.code === 'STICKER_IMAGE_INVALID' ? 415
|
|
@@ -6068,8 +6645,23 @@ export function apply(ctx, config) {
|
|
|
6068
6645
|
: result.code === 'STICKER_STORAGE' ? 500 : 400
|
|
6069
6646
|
return sendJson(res, status, result)
|
|
6070
6647
|
}
|
|
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`
|
|
6648
|
+
return sendJson(res, 200, { ok: true, name: result.name, bytes: result.bytes, mime: result.mime, source: 'collected', status: result.status,
|
|
6649
|
+
imageUrl: `/api/bili-duty/stickers/image?name=${encodeURIComponent(result.name)}&source=collected`
|
|
6650
|
+
+ (result.status === 'pending' ? `&status=pending&hash=${encodeURIComponent(result.hash)}` : '') })
|
|
6651
|
+
}
|
|
6652
|
+
// These decisions belong to the manual settings UI, never to bili_sticker/automatic tools.
|
|
6653
|
+
if (req.method === 'POST' && ['/api/bili-duty/stickers/approve', '/api/bili-duty/stickers/reject'].includes(path)) {
|
|
6654
|
+
let body
|
|
6655
|
+
try { body = await readBody(req, { strict: true }) }
|
|
6656
|
+
catch (error) { return sendJson(res, error.status || 400, { ok: false, code: error.code || 'INVALID_JSON', error: error.message }) }
|
|
6657
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.expectedHash !== 'string' || !/^[a-f0-9]{64}$/.test(body.expectedHash)) {
|
|
6658
|
+
return sendJson(res, 400, { ok: false, code: 'STICKER_INPUT', error: '请提供待审核表情的 name 和 expectedHash,重新查看图片后再决定' })
|
|
6659
|
+
}
|
|
6660
|
+
const decide = path.endsWith('/approve') ? approveSticker : rejectSticker
|
|
6661
|
+
const result = decide(stateDir, body.name, { expectedHash: body.expectedHash })
|
|
6662
|
+
const status = result.ok ? 200 : result.code === 'STICKER_INPUT' ? 400 : result.code === 'STICKER_NOT_FOUND' ? 404
|
|
6663
|
+
: result.code === 'STICKER_STORAGE' ? 500 : 409
|
|
6664
|
+
return sendJson(res, status, result)
|
|
6073
6665
|
}
|
|
6074
6666
|
if (req.method === 'POST' && path === '/api/bili-duty/stickers/forget') {
|
|
6075
6667
|
const body = await readBody(req)
|
|
@@ -6119,6 +6711,96 @@ export function apply(ctx, config) {
|
|
|
6119
6711
|
}
|
|
6120
6712
|
|
|
6121
6713
|
|
|
6714
|
+
/* Combined memory search is explicitly requested by the settings page.
|
|
6715
|
+
* Ordinary status polling never loads voice/history or walks every memory row. */
|
|
6716
|
+
if (path === '/api/bili-duty/memory') {
|
|
6717
|
+
const readText = (name) => {
|
|
6718
|
+
const file = join(stateDir, name)
|
|
6719
|
+
try {
|
|
6720
|
+
const info = statSync(file)
|
|
6721
|
+
if (info.size > 128 * 1024) throw new Error('MEMORY_FILE_TOO_LARGE: ' + name)
|
|
6722
|
+
return { text: readFileSync(file, 'utf8'), at: info.mtime.toISOString() }
|
|
6723
|
+
} catch (error) { if (error?.code === 'ENOENT') return { text: '', at: '' }; throw error }
|
|
6724
|
+
}
|
|
6725
|
+
const projection = (options = {}) => {
|
|
6726
|
+
const state = readJsonFileSync(dutyStateFile, {})
|
|
6727
|
+
const soul = readText(SOUL_FILE), voice = readText('voice.md')
|
|
6728
|
+
return memoryCenterSnapshot({ learn: state.learn, soulText: soul.text, soulAt: soul.at,
|
|
6729
|
+
voiceText: voice.text, voiceAt: voice.at, taste: state.taste, seen: state.seenVideos,
|
|
6730
|
+
events: [...readRecordEvents(stateDir, { type: 'memory', limit: 50, maxBytes: 128 * 1024 }),
|
|
6731
|
+
...readRecordEvents(stateDir, { type: 'learning', limit: 50, maxBytes: 128 * 1024 })]
|
|
6732
|
+
.sort((a, b) => String(b.at || b.recordedAt).localeCompare(String(a.at || a.recordedAt))).slice(0, 100),
|
|
6733
|
+
}, options)
|
|
6734
|
+
}
|
|
6735
|
+
if (req.method === 'GET') {
|
|
6736
|
+
const source = String(url.searchParams.get('source') || 'all')
|
|
6737
|
+
if (!MEMORY_SOURCES.includes(source)) return sendJson(res, 400, { ok: false, error: 'MEMORY_SOURCE_INVALID' })
|
|
6738
|
+
try { return sendJson(res, 200, projection({ source, filter: url.searchParams.get('filter'), offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') || 20 })) }
|
|
6739
|
+
catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6740
|
+
}
|
|
6741
|
+
if (req.method === 'POST') {
|
|
6742
|
+
const body = await readBody(req)
|
|
6743
|
+
const source = String(body?.source || ''), id = String(body?.id || ''), action = String(body?.action || '')
|
|
6744
|
+
const expected = String(body?.version || '')
|
|
6745
|
+
if (!['learning', 'soul', 'voice', 'taste'].includes(source) || !['forget', 'revoke'].includes(action) || !id || !/^[a-f0-9]{64}$/.test(expected)) {
|
|
6746
|
+
return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6747
|
+
}
|
|
6748
|
+
try {
|
|
6749
|
+
if (source === 'learning') {
|
|
6750
|
+
const result = await learnMutate((book) => {
|
|
6751
|
+
const row = book?.items?.[id]
|
|
6752
|
+
if (!row || memoryFingerprint(row) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6753
|
+
if (action === 'revoke' && row.state !== 'approved') return { ok: false, error: 'MEMORY_NOT_APPROVED' }
|
|
6754
|
+
return action === 'revoke' ? learnDecide(book, id, 'reject', '使用者从记忆中心撤销批准') : learnForget(book, { id })
|
|
6755
|
+
})
|
|
6756
|
+
if (result.ok === false) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6757
|
+
} else if (source === 'soul' || source === 'voice') {
|
|
6758
|
+
if (action !== 'forget') return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6759
|
+
const filename = source === 'soul' ? SOUL_FILE : 'voice.md'
|
|
6760
|
+
const result = withFileLockSync(join(stateDir, filename), () => {
|
|
6761
|
+
const current = readText(filename).text
|
|
6762
|
+
let next
|
|
6763
|
+
if (source === 'soul') {
|
|
6764
|
+
const match = /^(mood|crave|self|next):(\d{1,4})$/.exec(id)
|
|
6765
|
+
if (!match) return { ok: false, error: 'MEMORY_ACTION_INVALID' }
|
|
6766
|
+
const lines = soulSectionBody(current, match[1]).split(/\r?\n/)
|
|
6767
|
+
const index = Number(match[2])
|
|
6768
|
+
if (index >= lines.length || memoryFingerprint(lines[index]) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6769
|
+
lines.splice(index, 1)
|
|
6770
|
+
next = soulSetSection(current, match[1], lines.join('\n'))
|
|
6771
|
+
captureMemorySnapshot(stateDir, filename, current, { mode: 'dafeiyu', operation: 'before-manual-forget' })
|
|
6772
|
+
return writeSoul(stateDir, next)
|
|
6773
|
+
}
|
|
6774
|
+
if (!/^\d{1,5}$/.test(id)) return { ok: false, error: 'MEMORY_ACTION_INVALID' }
|
|
6775
|
+
const lines = current.split(/\r?\n/), index = Number(id)
|
|
6776
|
+
if (index >= lines.length || memoryFingerprint(lines[index]) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6777
|
+
lines.splice(index, 1); next = lines.join('\n')
|
|
6778
|
+
captureMemorySnapshot(stateDir, filename, current, { mode: 'dafeiyu', operation: 'before-manual-forget' })
|
|
6779
|
+
atomicWriteFileSync(join(stateDir, filename), next)
|
|
6780
|
+
return captureMemorySnapshot(stateDir, filename, next, { mode: 'dafeiyu', operation: 'manual-forget' })
|
|
6781
|
+
})
|
|
6782
|
+
if (!result.ok) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6783
|
+
} else {
|
|
6784
|
+
if (action !== 'forget' || !/^\d{1,32}$/.test(id)) return sendJson(res, 400, { ok: false, error: 'MEMORY_ACTION_INVALID' })
|
|
6785
|
+
const result = await withStateLock(() => {
|
|
6786
|
+
const state = readJsonFileSync(dutyStateFile, {}), row = state.taste?.[id]
|
|
6787
|
+
if (!row || memoryFingerprint(row) !== expected) return { ok: false, error: 'MEMORY_CHANGED_REFRESH_REQUIRED' }
|
|
6788
|
+
const next = { ...state.taste }; delete next[id]
|
|
6789
|
+
state.taste = next; state.tasteAt = new Date().toISOString()
|
|
6790
|
+
writeJson(dutyStateFile, state)
|
|
6791
|
+
return { ok: true }
|
|
6792
|
+
})
|
|
6793
|
+
if (!result.ok) return sendJson(res, 409, { ok: false, error: result.error })
|
|
6794
|
+
}
|
|
6795
|
+
const audit = act('note', `记忆中心:使用者${action === 'revoke' ? '撤销批准' : '忘记'}了一条 ${source} 记录(${id}),历史版本保留`,
|
|
6796
|
+
{ memoryAction: { source, id, action, version: expected, manual: true } })
|
|
6797
|
+
queueMirror('记忆中心更新')
|
|
6798
|
+
return sendJson(res, 200, { ok: true, source, id, action, auditRetained: Boolean(audit),
|
|
6799
|
+
...(audit ? {} : { warning: '当前记忆已更新,但本次操作审计没有成功归档,请查看运行日志' }) })
|
|
6800
|
+
} catch (error) { return sendJson(res, 200, { ok: false, error: String(error?.message || error) }) }
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6803
|
+
|
|
6122
6804
|
/* 🪞 她自己的账 —— 设置页能看、也能替她划掉一节(写是鱼自己的事,人不代笔)。
|
|
6123
6805
|
* ⚠️ 只开放「忘掉」:让人能替她写心情,这本账立刻就不真了。 */
|
|
6124
6806
|
if (path === '/api/bili-duty/soul') {
|
|
@@ -6217,7 +6899,12 @@ export function apply(ctx, config) {
|
|
|
6217
6899
|
}
|
|
6218
6900
|
|
|
6219
6901
|
if (req.method === 'POST' && path === '/api/bili-duty/config') {
|
|
6220
|
-
const body = await readBody(req)
|
|
6902
|
+
const body = await readBody(req, { strict: true })
|
|
6903
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return sendJson(res, 400, { ok: false, error: '配置 PATCH 必须是对象' })
|
|
6904
|
+
if (Object.hasOwn(body, 'resetTokensTotal') && typeof body.resetTokensTotal !== 'boolean') return sendJson(res, 400, { ok: false, error: 'resetTokensTotal 必须是 true 或 false' })
|
|
6905
|
+
for (const key of Object.keys(body)) {
|
|
6906
|
+
if (Object.hasOwn(DEFAULTS, key) && typeof DEFAULTS[key] === 'boolean' && typeof body[key] !== 'boolean') return sendJson(res, 400, { ok: false, error: key + ' 必须是 true 或 false' })
|
|
6907
|
+
}
|
|
6221
6908
|
const patch = {}
|
|
6222
6909
|
/* ⭐ 「一共花了多少」清零:它不是配置项(不落 config.json,也不进 DSH 设置页),
|
|
6223
6910
|
* 只是一次动作 ⇒ 在这里单独处理,别混进 clampPatch/persistCfg 那套。 */
|
|
@@ -6261,13 +6948,13 @@ export function apply(ctx, config) {
|
|
|
6261
6948
|
act('note', '把「一共花了多少 token」清零了')
|
|
6262
6949
|
} catch (e) { log(`总 token 清零失败:${e?.message ?? e}`) }
|
|
6263
6950
|
}
|
|
6264
|
-
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'transcribe', 'asrModel', 'idleChatter',
|
|
6951
|
+
for (const k of ['perDay', 'peakValley', 'noon', 'night', 'autoComment', 'sendReview', 'transcribe', 'asrModel', 'idleChatter',
|
|
6265
6952
|
...Object.keys(STICKER_DEFAULTS),
|
|
6266
6953
|
'commentPerRound', 'mentionName', 'dmMode', 'dmUid',
|
|
6267
6954
|
'autoReplyDm', 'dmReplyPerDay', 'dmReplyFollowedOnly',
|
|
6268
6955
|
'keywords', 'pythonPath', 'dshBin', 'headlessProfile', 'workspace', 'reportLevel', 'replyToComments', 'replyPerRound',
|
|
6269
6956
|
// 🐟 鲸鱼摸鱼模式那几个
|
|
6270
|
-
'persona', 'focus', 'autoMoment', 'momentPerDay', 'momentStyle', 'modelId', 'tokenBudget', 'totalTokenBudget', 'maxRounds',
|
|
6957
|
+
'persona', 'focus', 'autoMoment', 'momentPerDay', 'momentStyle', 'interactionStyle', 'modelId', 'tokenBudget', 'totalTokenBudget', 'maxRounds',
|
|
6271
6958
|
// ⏱️ 出手节奏的松紧(2026-10-21 m18802 第 1、2 条)
|
|
6272
6959
|
// ⚠️ 漏在这张表里 = 设置页画得出来、点了保存却被静默丢掉(dmReplyFollowedOnly 就漏过一次;
|
|
6273
6960
|
// 2026-10-26 复查又发现 pv 整组 8 个键 + overlayMcp 漏掉 ⇒ 自动做视频开关从来没存上过)
|
|
@@ -6278,6 +6965,8 @@ export function apply(ctx, config) {
|
|
|
6278
6965
|
'mirrorDir',
|
|
6279
6966
|
// 🎬 视频日报那组(clampPatch 都逐个处理了,这里不认就永远存不上)
|
|
6280
6967
|
'pvAuto', 'pvAt', 'pvVoice', 'pvDir',
|
|
6968
|
+
// 制作整条视频的模型;旧 pvModelId 只在读取配置时迁移。
|
|
6969
|
+
'videoModelId',
|
|
6281
6970
|
// 🎙 GPT-SoVITS 三件套 + 跑它的 python
|
|
6282
6971
|
'pvGsvRepo', 'pvGsvRef', 'pvGsvPrompt', 'pvGsvPython',
|
|
6283
6972
|
// 🐾 桌宠显隐(2026-10-26 桌宠化改造):桌宠菜单「隐藏桌宠」→ 存这里。
|
|
@@ -6285,6 +6974,7 @@ export function apply(ctx, config) {
|
|
|
6285
6974
|
'petShow',
|
|
6286
6975
|
...Object.keys(RUN_DEFAULTS),
|
|
6287
6976
|
...Object.keys(AUTOMATION_DEFAULTS),
|
|
6977
|
+
...Object.keys(COMPANION_DEFAULTS),
|
|
6288
6978
|
// 🔗 headless 那一轮挂不挂原生 MCP
|
|
6289
6979
|
'overlayMcp']) {
|
|
6290
6980
|
if (k in body) patch[k] = body[k]
|
|
@@ -6292,7 +6982,10 @@ export function apply(ctx, config) {
|
|
|
6292
6982
|
// ⭐ 范围钳制 + 两份都写(config.json / DSH 原生设置页),统一走 persistCfg —— 见文件上方
|
|
6293
6983
|
// ⭐ 私信那两个字段的合法值(2026-10-17 摘 PR #9;其余范围钳制统一在 persistCfg 里)
|
|
6294
6984
|
if ('dmMode' in patch) patch.dmMode = ['off', 'only', 'both'].includes(String(patch.dmMode)) ? String(patch.dmMode) : 'off'
|
|
6295
|
-
if ('dmUid' in patch)
|
|
6985
|
+
if ('dmUid' in patch) {
|
|
6986
|
+
try { patch.dmUid = configuredUid(patch.dmUid) }
|
|
6987
|
+
catch { return sendJson(res, 400, { ok: false, code: 'CONFIG_UID_INVALID', error: '收件 UID 必须是十进制字符串或安全非负整数;不接受已丢失精度的 number。' }) }
|
|
6988
|
+
}
|
|
6296
6989
|
const { clean, host, clamped } = await persistCfg(patch)
|
|
6297
6990
|
log(`设置已更新:${JSON.stringify(clean)}(DSH 那边${host.ok ? '已同步' : '没写成:' + host.error})`)
|
|
6298
6991
|
return sendJson(res, 200, { ok: true, config: cfg, host, clamped: clamped || [], limits: LIMITS })
|
|
@@ -6513,6 +7206,7 @@ export function apply(ctx, config) {
|
|
|
6513
7206
|
'(本包不含登录凭证;贴给别人前可以再自查一遍)',
|
|
6514
7207
|
'',
|
|
6515
7208
|
`插件版本:v${myVersion()} · 家目录:${stateDir}`,
|
|
7209
|
+
`工具作用域:${JSON.stringify(roleTools.snapshot())}`,
|
|
6516
7210
|
`dsh 命令:${line(dshCliCached()?.command || '(找不到)')} ok=${line(dshCliCached()?.ok)}`,
|
|
6517
7211
|
`pythonPath:${line(cfg.pythonPath)} · venvReady=${venvReadyNow()} 缺依赖:${missingVenvDeps().join(',') || '无'}`,
|
|
6518
7212
|
`登录:${hasCredential() ? '已登录' : '未登录'} · headlessProfile:${line(cfg.headlessProfile)} overlayMcp=${Boolean(cfg.overlayMcp)}`,
|
|
@@ -6539,41 +7233,25 @@ export function apply(ctx, config) {
|
|
|
6539
7233
|
return sendJson(res, 200, { ok: true, text, file: logFile })
|
|
6540
7234
|
}
|
|
6541
7235
|
|
|
6542
|
-
//
|
|
7236
|
+
// Only fixed server-owned directory selectors are accepted, never a client-supplied path.
|
|
6543
7237
|
if (req.method === 'POST' && path === '/api/bili-duty/open') {
|
|
6544
7238
|
const body = await readBody(req)
|
|
6545
|
-
const what =
|
|
7239
|
+
const what = body?.what === undefined ? 'out' : body.what
|
|
7240
|
+
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
7241
|
/* ⭐ `mirror`(m12873-3):打开「记录镜像」那个目录 ——
|
|
6547
|
-
* 它跟 out/digest 是两个地方(镜像在工作区根,产物在 bili-work 下),不能混。
|
|
7242
|
+
* 它跟 out/digest 是两个地方(镜像在工作区根,产物在 bili-work 下),不能混。
|
|
7243
|
+
* ⭐ `pv`:视频工作室的成片目录;只算服务端自己的 pvDirsOf,不接受任意路径。 */
|
|
6548
7244
|
const dir = what === 'home'
|
|
6549
7245
|
? stateDir
|
|
6550
7246
|
: what === 'mirror'
|
|
6551
7247
|
? (resolveMirrorDir(stateDir, cfg.mirrorDir) || outDir)
|
|
6552
|
-
:
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
['cmd.exe', ['/c', 'start', '', dir]],
|
|
6560
|
-
[join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe'), [dir]],
|
|
6561
|
-
['powershell.exe', ['-NoProfile', '-Command', "Start-Process -FilePath '" + dir.replace(/'/g, "''") + "'"]],
|
|
6562
|
-
]
|
|
6563
|
-
let lastErr = ''
|
|
6564
|
-
for (const [cmd, args] of attempts) {
|
|
6565
|
-
const err = await new Promise((resolve) => {
|
|
6566
|
-
let done = false
|
|
6567
|
-
let child
|
|
6568
|
-
try { child = spawn(cmd, args, { detached: true, stdio: 'ignore' }) } catch (e) { resolve(String(e?.message ?? e)); return }
|
|
6569
|
-
child.on('error', (e) => { if (!done) { done = true; resolve(String(e?.message ?? e)) } })
|
|
6570
|
-
setTimeout(() => { if (!done) { done = true; try { child.unref() } catch { /* 无所谓 */ } resolve('') } }, 500)
|
|
6571
|
-
})
|
|
6572
|
-
if (!err) { log('打开文件夹:用了 ' + cmd + '(没报错;窗口不一定真弹出来了)'); return sendJson(res, 200, { ok: true, opened: dir, via: cmd }) }
|
|
6573
|
-
lastErr = err
|
|
6574
|
-
log('打开文件夹失败(' + cmd + '):' + err)
|
|
6575
|
-
}
|
|
6576
|
-
return sendJson(res, 200, { ok: false, error: lastErr, dir })
|
|
7248
|
+
: what === 'pv'
|
|
7249
|
+
? pvDirsOf().out
|
|
7250
|
+
: outDir
|
|
7251
|
+
const result = await openDirectory(dir)
|
|
7252
|
+
if (result.ok) log('打开目录请求已由系统接收:' + result.via)
|
|
7253
|
+
else log('打开目录失败:' + result.error)
|
|
7254
|
+
return sendJson(res, 200, result)
|
|
6577
7255
|
}
|
|
6578
7256
|
|
|
6579
7257
|
/* 🚫 2026-10-21(使用者 m18802 第 4 条「把这个插件封装一下,改成一个新插件,删除掉他的更新」):
|
|
@@ -6596,19 +7274,20 @@ export function apply(ctx, config) {
|
|
|
6596
7274
|
|
|
6597
7275
|
// ── 扫码登录(设置页用)──
|
|
6598
7276
|
if (req.method === 'POST' && path === '/api/bili-duty/login/start') {
|
|
6599
|
-
const { key, url } =
|
|
7277
|
+
const attempt = await loginSession.start(), { key, url } = attempt
|
|
6600
7278
|
const png = await renderQrPng(cfg.pythonPath, stateDir, url)
|
|
7279
|
+
if (!loginSession.current(attempt)) return sendJson(res, 409, { ok: false, error: '登录请求已被新的二维码替代,请重新查看最新二维码' })
|
|
6601
7280
|
let qr = null
|
|
6602
7281
|
if (png) { try { qr = 'data:image/png;base64,' + readFileSync(png).toString('base64') } catch { qr = null } }
|
|
6603
|
-
log(
|
|
7282
|
+
log(qr ? '已生成登录二维码(不记录登录 key 或 URL)' : '二维码图片未生成,请检查 qrcode 依赖(不记录登录 key 或 URL)')
|
|
6604
7283
|
return sendJson(res, 200, { ok: true, key, url, qr })
|
|
6605
7284
|
}
|
|
6606
7285
|
|
|
6607
7286
|
if (req.method === 'POST' && path === '/api/bili-duty/login/poll') {
|
|
6608
7287
|
const body = await readBody(req)
|
|
6609
|
-
const r = await
|
|
7288
|
+
const r = await loginSession.poll(String(body.key || ''))
|
|
6610
7289
|
if (r.state === 'ok') {
|
|
6611
|
-
const file =
|
|
7290
|
+
const file = r.file
|
|
6612
7291
|
log(`登录成功,凭证已写入 ${file}`)
|
|
6613
7292
|
return sendJson(res, 200, { ok: true, state: 'ok', file })
|
|
6614
7293
|
}
|
|
@@ -6629,33 +7308,32 @@ export function apply(ctx, config) {
|
|
|
6629
7308
|
gsvRepo: cfg.pvGsvRepo || '', gsvRef: cfg.pvGsvRef || '',
|
|
6630
7309
|
gsvPrompt: cfg.pvGsvPrompt || '', gsvPython: cfg.pvGsvPython || '',
|
|
6631
7310
|
yesterday: pvYesterday(), out: d.out, work: d.work, root: d.root,
|
|
7311
|
+
modelId: cfg.videoModelId || '', sessions: videoStudio.list(),
|
|
6632
7312
|
...s,
|
|
6633
7313
|
})
|
|
6634
7314
|
}
|
|
7315
|
+
if (req.method === 'POST' && path === '/api/bili-duty/pv/session-new') {
|
|
7316
|
+
const body = await readBody(req)
|
|
7317
|
+
return sendJson(res, 200, await videoStudio.start(body))
|
|
7318
|
+
}
|
|
6635
7319
|
if (req.method === 'POST' && path === '/api/bili-duty/pv') {
|
|
6636
7320
|
const body = await readBody(req)
|
|
6637
|
-
const what = String(body?.action || '
|
|
6638
|
-
if (what === 'stop') return sendJson(res, 200,
|
|
7321
|
+
const what = String(body?.action || 'session').toLowerCase()
|
|
7322
|
+
if (what === 'stop') return sendJson(res, 200, videoStudio.stop(String(body?.sessionId || '')))
|
|
6639
7323
|
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}` })
|
|
7324
|
+
if (what === 'make' || what === 'session') return sendJson(res, 200, await videoStudio.start(body))
|
|
7325
|
+
return sendJson(res, 200, { ok: false, error: `不认识的 action:${what};请用 session 新开制作会话` })
|
|
6648
7326
|
}
|
|
6649
7327
|
/* 把成品 mp4 递给浏览器 —— 设置页要能直接播。
|
|
6650
7328
|
* ⚠️ **必须支持 Range**:`<video>` 拖动进度条发的就是 `Range: bytes=…`,
|
|
6651
7329
|
* 不回 206 的话浏览器只能从头放到尾(大文件还会干脆不出画面)。
|
|
6652
|
-
*
|
|
6653
|
-
* 否则这个接口就是"读服务器上任何文件"的后门。 */
|
|
7330
|
+
* 只接受验证成片的 production id 或旧成片日期,不接受任意文件路径。 */
|
|
6654
7331
|
if (req.method === 'GET' && path === '/api/bili-duty/pv/video') {
|
|
6655
|
-
const
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
7332
|
+
const id = url.searchParams.get('id')
|
|
7333
|
+
const day = String(url.searchParams.get('day') || '')
|
|
7334
|
+
if (id === null && !pvDateOk(day)) return sendJson(res, 400, { ok: false, error: '请提供成片 id 或 YYYY-MM-DD 格式的 day' })
|
|
7335
|
+
const file = id === null ? pv.fileOf(day) : pv.productionFileOf(id)
|
|
7336
|
+
if (!file) return sendJson(res, 404, { ok: false, error: '还没有可播放的已完成视频' })
|
|
6659
7337
|
let size = 0
|
|
6660
7338
|
try { size = statSync(file).size } catch { return sendJson(res, 404, { ok: false, error: '这天还没有视频' }) }
|
|
6661
7339
|
const range = String(req.headers?.range || '')
|
|
@@ -6682,7 +7360,9 @@ export function apply(ctx, config) {
|
|
|
6682
7360
|
rs.on('error', () => { try { res.destroy() } catch { /* 对端走了 */ } })
|
|
6683
7361
|
return rs.pipe(res)
|
|
6684
7362
|
}
|
|
6685
|
-
|
|
7363
|
+
const download = url.searchParams.get('download') === '1'
|
|
7364
|
+
res.writeHead(200, { 'content-type': 'video/mp4', 'accept-ranges': 'bytes', 'content-length': String(size), 'cache-control': 'no-store',
|
|
7365
|
+
...(download ? { 'content-disposition': `attachment; filename="DeepMoYu-${String(day || id).replace(/[^0-9A-Za-z-]/g, "")}.mp4"` } : {}) })
|
|
6686
7366
|
const rs = createReadStream(file)
|
|
6687
7367
|
rs.on('error', () => { try { res.destroy() } catch { /* 对端走了 */ } })
|
|
6688
7368
|
return rs.pipe(res)
|
|
@@ -6715,13 +7395,15 @@ export function apply(ctx, config) {
|
|
|
6715
7395
|
path: '/api/bili-duty',
|
|
6716
7396
|
handler: async (req, res) => {
|
|
6717
7397
|
try { await handle(req, res, new URL(req.url, 'http://127.0.0.1')) } catch (e) {
|
|
6718
|
-
|
|
7398
|
+
const status = Number.isInteger(e?.status) && e.status >= 400 && e.status < 500 ? e.status : 500
|
|
7399
|
+
sendJson(res, status, { ok: false, error: e?.message ?? String(e) })
|
|
6719
7400
|
}
|
|
6720
7401
|
},
|
|
6721
7402
|
}), 'bili-duty: /api/bili-duty 路由')
|
|
6722
7403
|
} catch (e) { log(`HTTP 路由没挂上:${e?.message ?? e}`) }
|
|
6723
7404
|
|
|
6724
7405
|
ctx.effect(() => () => {
|
|
7406
|
+
loginSession.dispose()
|
|
6725
7407
|
stickerAutomation.stop()
|
|
6726
7408
|
for (const scope of stickerSessionScopes.values()) scope.abort(new Error('插件已卸载'))
|
|
6727
7409
|
stickerSessionScopes.clear()
|
|
@@ -6773,10 +7455,11 @@ export function apply(ctx, config) {
|
|
|
6773
7455
|
const due = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, 0, 0)
|
|
6774
7456
|
if (now < due) return // 还没到点
|
|
6775
7457
|
const y = pvYesterday(now)
|
|
6776
|
-
if (!pv.needsWork(y)) return
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
7458
|
+
if (!pv.needsWork(y) || pv.productions().some(item => item.mode === 'daily' && item.date === y)) return
|
|
7459
|
+
// A durable session, including failed/paused authoring, prevents another paid request every tick.
|
|
7460
|
+
if (videoStudio.list().some(item => item.scheduled && item.date === y)) return
|
|
7461
|
+
const r = await videoStudio.start({ date: y, mode: 'daily', targetDurationSec: 90, scheduled: true })
|
|
7462
|
+
log(r.ok ? `🎬 自动制作 ${y}:已新开可见会话 ${r.sessionId}` : `🎬 自动做 ${y} 的视频没起来:${r.error}`)
|
|
6780
7463
|
} catch (e) { log(`🎬 视频定时器这一跳出错(已兜住):${e?.stack ?? e}`) }
|
|
6781
7464
|
}
|
|
6782
7465
|
const t = setInterval(() => { pvTick() }, 3 * 60_000)
|