cortico-world-mosey 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,49 @@
1
1
  # 更新日志
2
2
 
3
- ## 2.0.3
3
+ ## 2.1.0
4
+
5
+ **出门方式自己选:按次数 / 按额度。** 以前「每天出门几次」和「每日额度」两道闸同时生效,谁先撞上谁停——次数 8/8 之后必做还没做完,就只能干等到第二天。现在二选一,只留一道闸门。
6
+
7
+ ### 新的「出门方式」
8
+
9
+ - **按次数**(默认,和以前一样):每天出去几次就收工,额度只当单个动作的上限。
10
+ - **按额度**:出去几次不限,把勾了必做的那几项做满才收工。适合「今天想让她把评论和点赞做完」。
11
+ - 只追勾了必做的项;一项都没勾时退回全部主动额度。
12
+ - 私信 / 群聊不算——没人找你时这两项永远做不满,算进去会变成一直出门却没事干。
13
+ - 被关掉的动作(不允许关注、只读模式、两个平台都关注不了……)不算目标,免得额度永远填不满、白跑。
14
+ - 还堆着 8 条以上草稿没批时先不出门:「等我批准」模式下草稿不涨额度,不拦会越堆越多。
15
+ - **不自己出门**:只在你叫的时候逛。
16
+ - 老配置自动迁移:以前「每天自己出门 = 0」→ 不自己出门,其余 → 按次数,行为不变。
17
+
18
+ ### 必做在两种模式下的分工
19
+
20
+ - 按额度:勾了的项就是今天的目标,做满才收工;晚上没做完会催(最多 3 次、间隔 30 分钟),只催真正的目标项。
21
+ - 按次数:必做只决定「先补哪个」,不催、也不压缩出门间隔——切模式不用再跑去把几个勾取消掉。
22
+
23
+ ### 被限流不再硬撞
24
+
25
+ - `-509 / 429` 按 2 → 5 → 10 → 20 → 30 分钟退避(封顶 30 分钟),一小时内没再撞就从头算。
26
+ - 只拦「她自己出门」的动作;你当场让她做的、回你私信的一律照旧(以前一刀切,限流一次连你叫都不应)。
27
+ - 面板会写「限流歇 N 分钟」,不用再猜她为什么不动。
28
+
29
+ ### 面板和设置页
30
+
31
+ - 「自己出门」卡片按模式显示:按次数是 `5/8`;按额度是 `5 次 · 额度 7/24`,并直接写「还差 赞 17、评 3」。
32
+ - 出门推送同样列出还差哪几项,让她先补差距大的。
33
+ - 设置页重排:基础 → 主人 → 公开动作 → 每日额度 → **自己出门** → 私信与群 → 看视频 → 一起看 → 抖音一起刷 → 高级。
34
+ - 「出不出去 / 一天几次 / 间隔 / 时段 / 安静时段 / 每次看几条 / 模型预算」全部收进「自己出门」。
35
+ - 每个「每天最多 X」下面紧跟「必做:X」,一眼看得出哪些被当成目标。
36
+
37
+ ### 其它
38
+
39
+ - `stroll action=brief` 多给 `outingMode` / `autoSessions` / `quotaLeft`,她按模式决定还要不要继续出门。
40
+
41
+ ## 2.0.4
4
42
 
5
43
  **继续脱敏,并瘦身。** 上一次漏了 `docs/` 里的面板预览页(假数据里有真实昵称和 B站 uid)。现在预览页也换成虚构占位,并且 `docs/` 不再随 npm 包分发——那几张截图只是市场素材,没必要塞进安装包(顺带把安装包瘦了 1.5MB)。
6
44
 
45
+ > 2.0.3 与 2.0.4 内容完全相同:发布 2.0.3 时 npm 还在异步处理,误判成失败后换号重发,两版代码一致。
46
+
7
47
  ## 2.0.2
8
48
 
9
49
  **文档脱敏。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-mosey",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
6
6
  "license": "MIT",
package/src/ENV_PROMPT.md CHANGED
@@ -21,7 +21,7 @@
21
21
  ### 怎么逛(省 token)
22
22
 
23
23
  - 一次 `stroll action=brief` 拿全:登录、额度、3 条候选、3 条消息。不要连着 `status`→`feed`→`inbox` 刷三遍。
24
- - **必做日**:`brief` 里有 `mustDo`(勾了「必做」的项,带 `need/done/left`)。只要有一项 `left>0`,这次出门就先把它们补够——`left` 是几次就做几次,别点一条就交差。晚上收到「今天还有必做没完成」的推送,立刻去补,不用等下一次出门。
24
+ - **必做日**:`brief` 里有 `mustDo`(勾了「必做」的项,带 `need/done/left`)。只要有一项 `left>0`,这次出门就先把它们补够——`left` 是几次就做几次,别点一条就交差。**出门方式由主人选**:`brief.status.outingMode` 是 `sessions`(按次数:出去几次就收工,必做只决定先补哪个,晚上不会额外催你)或 `quota`(按额度:次数不限,**只有勾了必做的项算目标**,`quotaLeft` 是这些目标还剩多少个,做满才收工;没勾必做的额度只当上限,不用为了“做满”去硬做),`off` 是主人没让你自己出门。私信/群聊任何时候都不算“没做完”。收到「今天还有必做没完成」的推送(只有按额度模式会发),立刻去补,不用等下一次出门。
25
25
  - 「私信」「群聊」这两项要看有没有对象:没人给你发消息、群里也没人说话时,**不要为了凑数去私聊陌生人或硬插话**,如实告诉主人「今天没人找我说话,私信/群聊这项补不上」。
26
26
  - 从候选里挑 **1-3 条**真的想看的(按上面的活跃度来),再 `stroll action=open`。剩下的不点、不看、不评论。
27
27
  - 默认是**后台看**:不会弹出网页,也不会抢主人的鼠标。只有「一起看」和扫码登录会开窗。
@@ -146,7 +146,7 @@
146
146
  - 抖音的 @ 通知有时不弹(平台侧限流、对方关掉了被 @ 提醒):`mentioned=true` 只代表评论里真的带了 @。主人说「没看到」时别争,把视频链接私信发过去,说清评论大概内容、让他按「最新」排一下;实在找不到就重新发一条,不要连刷。
147
147
  - **评论回执里的 `status` / `visible` / `note` 要看**:`visible=true`(status=1)才是别人也能看见;`status=2` 是「只有你自己看得见」,别人和被 @ 的人都看不到,也不会有通知——`note` 里写的就是原因和替代办法,照着说给主人听,别嘴硬、也别重发同一条。`status=7` 是还在审核,过一会儿再看。
148
148
  - **图文动态(`/note/` 链接)一样能评论、点赞**:走的是和视频同一条路,只是评论框要多展开一次(代码里已经处理好了)。如果回执说 `NO_INPUT`「找不到评论输入框」,先确认这条是不是关了评论,别硬重试。
149
- - **风控默认不拦人**:`-509 操作频繁` 这类限流只记一笔,不暂停任何动作,也不用干等到某个时间点。真发不出去平台会当场报错,那时如实告诉主人「抖音这边暂时发不出,稍后再试」。只有要短信验证码/封号这种情况才真的要停手。
149
+ - **风控默认不拦人,但限流会让她自己歇几分钟**:`-509 操作频繁` 这类限流只记一笔,不暂停审批,也不拦主人当场让你做的事;**你自己出门的动作用完会按 2/5/10/20 分钟退避(最多 30 分钟)**,这段时间调 `stroll_act` 会返回 `SOFT_WAIT`——看到就老实等,别硬试,如实告诉主人「抖音这边限流了,歇几分钟再补」。只有要短信验证码/封号这种(`RISK_PAUSE`)才真的要停手。
150
150
  - **抖音评论不带 @ 时走的是接口直发**:`stroll_act action=comment` 一两秒就出结果(`via=api`)。带 `mention_owner` 的才需要点评论框,慢几秒——那是插 @ chip 必须的,别因为慢就自己去掉 @。
151
151
  - 评论发没发出去,以回执里的 `confirmed` / `queued` 为准(`confirmed=true` 是接口里能查到这条评论)。`queued=true` 说明平台还没回显,别立刻发第二遍。
152
152
  - 公开动作默认进草稿箱,等主人在「摸鱼」面板点批准。**你不能自己批准**,也不要催第二次;主人没点就是不想发。
package/src/config.js CHANGED
@@ -75,7 +75,13 @@ export const DEFAULTS = {
75
75
  /** 她自己出门时的动作间隔;主人当场让她做的事不走这个数(只防连点)。 */
76
76
  minGapSeconds: 20,
77
77
 
78
- /** 0 = 不自己出门;默认一天五次,比较像会自己刷手机的人。 */
78
+ /**
79
+ * 出门方式:off=不自己出门 / sessions=按次数 / quota=按额度。
80
+ * 以前是「次数」和「额度」两道闸门同时生效,谁先撞谁停——
81
+ * 次数用完之后必做还没做完,就只能干等到明天。
82
+ */
83
+ outingMode: 'sessions',
84
+ /** 0 = 不自己出门(等同于 outingMode=off);按次数模式时一天出门几次。 */
79
85
  autoSessionsPerDay: 5,
80
86
  /** 两次自动出门之间至少隔多少分钟;和主人手动互动无关。 */
81
87
  autoGapMinutes: 90,
@@ -141,7 +147,7 @@ export const DEFAULTS = {
141
147
 
142
148
  const P = 'worlds.stroll.';
143
149
 
144
- /** 基础:开关、平台、放行方式、身份。 */
150
+ /** 基础:开关、平台、放行方式。 */
145
151
  const BASIC = {
146
152
  id: 'world:stroll',
147
153
  owner: 'world:stroll',
@@ -180,42 +186,6 @@ const BASIC = {
180
186
  'x-hot': true,
181
187
  'x-warning': true,
182
188
  },
183
- [P + 'autoSessionsPerDay']: {
184
- type: 'integer',
185
- title: '每天自己出门',
186
- description: '0=只在你叫的时候;5 次像正常人刷手机。',
187
- minimum: 0,
188
- maximum: 10,
189
- 'x-suffix': ' 次/天',
190
- 'x-hot': true,
191
- },
192
- [P + 'autoGapMinutes']: {
193
- type: 'integer',
194
- title: '两次出门至少隔',
195
- description: '太密容易撞风控;90 分钟≈一天 5 次。',
196
- minimum: 30,
197
- maximum: 360,
198
- 'x-suffix': ' 分钟',
199
- 'x-hot': true,
200
- },
201
- [P + 'outingFrom']: {
202
- type: 'integer',
203
- title: '出门时段:从几点',
204
- description: '只在这个时间段自己出门。想只走谷时可以填 1 到 6。',
205
- minimum: 0,
206
- maximum: 23,
207
- 'x-suffix': ' 点',
208
- 'x-hot': true,
209
- },
210
- [P + 'outingTo']: {
211
- type: 'integer',
212
- title: '出门时段:到几点',
213
- description: '填 24 表示到当天结束。和上面填一样的值=全天都能出门;跨天也行,比如 22 到 6。',
214
- minimum: 0,
215
- maximum: 24,
216
- 'x-suffix': ' 点',
217
- 'x-hot': true,
218
- },
219
189
  },
220
190
  },
221
191
  };
@@ -266,7 +236,8 @@ const QUOTA = {
266
236
  schema: {
267
237
  type: 'object',
268
238
  title: '每日额度',
269
- description: '每天最多做多少,自己填。勾「必做」就从上限变成目标;周上限自动是日上限 × 7。',
239
+ description:
240
+ '每天最多做多少。「必做」=当成今天的目标:按额度出门时,勾了的要做满才收工;按次数出门时只决定先补哪个,不会额外催她出门(所以切模式不用取消勾选)。没勾的只当上限。周上限自动是日上限 × 7。',
270
241
  properties: {
271
242
 
272
243
  [P + 'dailyLikes']: {
@@ -276,6 +247,11 @@ const QUOTA = {
276
247
  maximum: 200,
277
248
  'x-suffix': ' 次',
278
249
  },
250
+ [P + 'mustLike']: {
251
+ type: 'boolean',
252
+ title: '必做:点赞',
253
+ description: '勾上后,今天要做满「每天最多点赞」的次数;按额度出门时这是收工条件之一。',
254
+ },
279
255
  [P + 'dailyComments']: {
280
256
  type: 'integer',
281
257
  title: '每天最多评论',
@@ -283,6 +259,11 @@ const QUOTA = {
283
259
  maximum: 100,
284
260
  'x-suffix': ' 条',
285
261
  },
262
+ [P + 'mustComment']: {
263
+ type: 'boolean',
264
+ title: '必做:评论',
265
+ description: '勾上后,今天要做满「每天最多评论」的条数。审批不是 auto 时评论会进草稿,批准后才算完成。',
266
+ },
286
267
  [P + 'dailyDms']: {
287
268
  type: 'integer',
288
269
  title: '每天最多私信',
@@ -291,6 +272,12 @@ const QUOTA = {
291
272
  minimum: 0,
292
273
  'x-suffix': ' 条',
293
274
  },
275
+ [P + 'mustDm']: {
276
+ type: 'boolean',
277
+ title: '必做:私信',
278
+ description:
279
+ '勾上后,今天要发够「每天最多私信」的条数,晚上没做完会提醒你。但按额度出门不拿它当收工条件——没人给你发消息时,硬凑会变成骚扰陌生人。',
280
+ },
294
281
  [P + 'dailyGroups']: {
295
282
  type: 'integer',
296
283
  title: '每天最多群聊',
@@ -298,6 +285,11 @@ const QUOTA = {
298
285
  minimum: 0,
299
286
  'x-suffix': ' 条',
300
287
  },
288
+ [P + 'mustGroup']: {
289
+ type: 'boolean',
290
+ title: '必做:群聊',
291
+ description: '勾上后,今天要在群里说够「每天最多群聊」的条数。**按额度出门也不拿它当收工条件**(群里没人说话时不能硬插话)。',
292
+ },
301
293
  [P + 'dailyFollows']: {
302
294
  type: 'integer',
303
295
  title: '每天最多关注',
@@ -305,6 +297,11 @@ const QUOTA = {
305
297
  maximum: 100,
306
298
  'x-suffix': ' 人',
307
299
  },
300
+ [P + 'mustFollow']: {
301
+ type: 'boolean',
302
+ title: '必做:关注',
303
+ description: '勾上后,今天要关注够「每天最多关注」的人数;按额度出门时这是收工条件之一。',
304
+ },
308
305
  [P + 'dailyPosts']: {
309
306
  type: 'integer',
310
307
  title: '每天最多发动态',
@@ -312,6 +309,11 @@ const QUOTA = {
312
309
  maximum: 30,
313
310
  'x-suffix': ' 条',
314
311
  },
312
+ [P + 'mustPost']: {
313
+ type: 'boolean',
314
+ title: '必做:发动态',
315
+ description: '勾上后,今天要发够「每天最多发动态」的条数。审批不是 auto 时会进草稿,批准后才算完成。',
316
+ },
315
317
  [P + 'dailySaves']: {
316
318
  type: 'integer',
317
319
  title: '每天最多稍后再看',
@@ -319,36 +321,6 @@ const QUOTA = {
319
321
  maximum: 100,
320
322
  'x-suffix': ' 条',
321
323
  },
322
- [P + 'mustLike']: {
323
- type: 'boolean',
324
- title: '必做:点赞',
325
- description: '勾上后,今天必须完成「每天最多点赞」的次数,没完成她会优先去补。',
326
- },
327
- [P + 'mustComment']: {
328
- type: 'boolean',
329
- title: '必做:评论',
330
- description: '勾上后,今天必须完成「每天最多评论」的条数。审批不是 auto 时评论会进草稿,批准后才算完成。',
331
- },
332
- [P + 'mustDm']: {
333
- type: 'boolean',
334
- title: '必做:私信',
335
- description: '勾上后,今天必须发够「每天最多私信」的条数。没有可回的人时她会告诉你,不会硬找陌生人发。',
336
- },
337
- [P + 'mustGroup']: {
338
- type: 'boolean',
339
- title: '必做:群聊',
340
- description: '勾上后,今天必须在群里说够「每天最多群聊」的条数。群里没人说话时不算她的错,她会说明。',
341
- },
342
- [P + 'mustFollow']: {
343
- type: 'boolean',
344
- title: '必做:关注',
345
- description: '勾上后,今天必须关注够「每天最多关注」的人数。',
346
- },
347
- [P + 'mustPost']: {
348
- type: 'boolean',
349
- title: '必做:发动态',
350
- description: '勾上后,今天必须发够「每天最多发动态」的条数。审批不是 auto 时会进草稿,批准后才算完成。',
351
- },
352
324
 
353
325
  },
354
326
  },
@@ -399,23 +371,61 @@ const INTERACT = {
399
371
  },
400
372
  };
401
373
 
402
- /** 高级:兴趣、省 token、一起看、风控。 */
403
- const ADVANCED = {
404
- id: 'world:stroll-advanced',
374
+ /** 自己出门:方式、次数、间隔、时段、预算——她自己的节奏全在这一组。 */
375
+ const OUTING = {
376
+ id: 'world:stroll-outing',
405
377
  owner: 'world:stroll',
406
378
  schema: {
407
379
  type: 'object',
408
- title: '高级与节奏',
409
- description: '一般不用改。改错最多多花点 token 或多等一会儿。',
380
+ title: '自己出门',
381
+ description:
382
+ '她什么时候出去、一天出去几次、被什么挡住。按次数=出去几次就收工;按额度=把勾了必做的项做满才收工。',
410
383
  properties: {
411
-
412
- [P + 'tokenDailyBudget']: {
384
+ [P + 'outingMode']: {
385
+ type: 'string',
386
+ title: '出门方式',
387
+ 'x-options': 'stroll-outing-mode',
388
+ description:
389
+ '按次数:每天出去几次就收工,额度只当单个动作的上限。' +
390
+ '按额度:出去几次不限,把勾了必做的项做满才收工(一个都没勾时按全部主动额度算);' +
391
+ '私信/群聊不算,没人找你时这两项永远做不满。没勾必做的额度只当上限,不会逼她做。',
392
+ 'x-hot': true,
393
+ },
394
+ [P + 'autoSessionsPerDay']: {
413
395
  type: 'integer',
414
- title: '每天模型预算',
415
- description: '只限制主动出门的频率,不拦你找她。',
416
- minimum: 1000,
417
- maximum: 50000,
418
- 'x-suffix': ' token',
396
+ title: '每天自己出门',
397
+ description: '只在「出门方式=按次数」时生效;0=不自己出门。',
398
+ minimum: 0,
399
+ maximum: 10,
400
+ 'x-suffix': ' 次/天',
401
+ 'x-hot': true,
402
+ },
403
+ [P + 'autoGapMinutes']: {
404
+ type: 'integer',
405
+ title: '两次出门至少隔',
406
+ description: '太密容易撞风控;90 分钟≈一天 5 次。按额度模式也按这个间隔走。',
407
+ minimum: 30,
408
+ maximum: 360,
409
+ 'x-suffix': ' 分钟',
410
+ 'x-hot': true,
411
+ },
412
+ [P + 'outingFrom']: {
413
+ type: 'integer',
414
+ title: '出门时段:从几点',
415
+ description: '只在这个时间段自己出门。想只走谷时可以填 1 到 6。',
416
+ minimum: 0,
417
+ maximum: 23,
418
+ 'x-suffix': ' 点',
419
+ 'x-hot': true,
420
+ },
421
+ [P + 'outingTo']: {
422
+ type: 'integer',
423
+ title: '出门时段:到几点',
424
+ description: '填 24 表示到当天结束。和上面填一样的值=全天都能出门;跨天也行,比如 22 到 6。',
425
+ minimum: 0,
426
+ maximum: 24,
427
+ 'x-suffix': ' 点',
428
+ 'x-hot': true,
419
429
  },
420
430
  [P + 'quietFrom']: {
421
431
  type: 'integer',
@@ -449,6 +459,28 @@ const ADVANCED = {
449
459
  maximum: 12,
450
460
  'x-suffix': ' 条',
451
461
  },
462
+ [P + 'tokenDailyBudget']: {
463
+ type: 'integer',
464
+ title: '每天模型预算',
465
+ description: '按每出门一次约 1500 token 折算成「今天最多还能出门几次」,两种出门方式都受它限制。',
466
+ minimum: 1000,
467
+ maximum: 50000,
468
+ 'x-suffix': ' token',
469
+ },
470
+ },
471
+ },
472
+ };
473
+
474
+ /** 高级:兴趣、评论、风控、图片目录。 */
475
+ const ADVANCED = {
476
+ id: 'world:stroll-advanced',
477
+ owner: 'world:stroll',
478
+ schema: {
479
+ type: 'object',
480
+ title: '高级',
481
+ description: '一般不用改。改错最多多花点 token 或多等一会儿。',
482
+ properties: {
483
+
452
484
  [P + 'commentMaxChars']: {
453
485
  type: 'integer',
454
486
  title: '评论最多几个字',
@@ -781,7 +813,12 @@ const MESSAGES = {
781
813
  },
782
814
  };
783
815
 
784
- export const CONFIG_GROUPS = [BASIC, OWNER, QUOTA, VIEWING, WATCH, SHORTS, MESSAGES, INTERACT, ADVANCED];
816
+ /**
817
+ * 设置页的顺序(从「最常改」到「一般不动」):
818
+ * 基础 → 主人 → 公开动作(她能做什么)→ 每日额度(做多少 / 哪些必做)
819
+ * → 自己出门(怎么出去)→ 私信与群 → 看视频 → 一起看 → 抖音一起刷 → 高级
820
+ */
821
+ export const CONFIG_GROUPS = [BASIC, OWNER, INTERACT, QUOTA, OUTING, MESSAGES, VIEWING, WATCH, SHORTS, ADVANCED];
785
822
 
786
823
  /** 旧引用兼容:第一组即基础组。 */
787
824
 
@@ -835,6 +872,7 @@ export const POLICY_FIELDS = {
835
872
  minGapSeconds: { type: 'int', min: 10, max: 600 },
836
873
  riskGuard: { type: 'enum', values: ['off', 'light', 'strict'] },
837
874
  autoSessionsPerDay: { type: 'int', min: 0, max: 10 },
875
+ outingMode: { type: 'enum', values: ['off', 'sessions', 'quota'] },
838
876
  autoGapMinutes: { type: 'int', min: 30, max: 360 },
839
877
  outingFrom: { type: 'int', min: 0, max: 23 },
840
878
  outingTo: { type: 'int', min: 0, max: 24 },
package/src/definition.js CHANGED
@@ -60,6 +60,13 @@ export const STROLL = {
60
60
  { value: 'strict', label: zh ? '严格' : 'Strict' },
61
61
  ];
62
62
  }
63
+ if (kind === 'stroll-outing-mode') {
64
+ return [
65
+ { value: 'sessions', label: zh ? '按次数:出去几次就收工(默认)' : 'By trip count (default)' },
66
+ { value: 'quota', label: zh ? '按额度:额度做满才收工' : 'By daily quota' },
67
+ { value: 'off', label: zh ? '不自己出门' : 'Never go out on its own' },
68
+ ];
69
+ }
63
70
  if (kind !== 'stroll-approval') return [];
64
71
  return [
65
72
  { value: 'confirm', label: zh ? '全部等我批准(推荐)' : 'Ask me first (recommended)' },
package/src/policy.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { clamp, dayKey, hourOf, weekKey } from './util.js';
2
2
 
3
+ /**
4
+ * -509 / 429 这类「只是太快了」的限流:按撞墙次数退避 2 / 5 / 10 / 20 分钟,封顶 30 分钟。
5
+ * 和 riskGuard 解耦——riskGuard 管「要不要把所有动作停掉」,这里只管「她自己出门的节奏」,
6
+ * 主人当场点的、回主人私信的一律不拦(不然被限流一次就连主人叫都不应了)。
7
+ */
8
+ const RATE_BACKOFF_MIN = [2, 5, 10, 20, 30];
9
+ /** 离上次限流超过一小时,就当上一波已经过去,退避从头算。 */
10
+ const RATE_RESET_MS = 60 * 60 * 1000;
11
+
3
12
  /**
4
13
  * 日额度直接读配置;周额度 = 日额度 × 7(不再另设一个写死的周上限)。
5
14
  * 以前 follow 的 weeklyMax 是 12——即使主人把日额度调到 5,一周也只能关注 12 个,
@@ -86,6 +95,24 @@ export class Policy {
86
95
  return { paused: true, until, reason };
87
96
  }
88
97
 
98
+ /**
99
+ * 限流退避:`{ waiting, until, minutes, n, reason }`。
100
+ * 只拦「她自己出门」时做的动作;主人当场点的、回主人私信的不受影响。
101
+ */
102
+ rateWait() {
103
+ const soft = this.store.data.softBackoff ?? {};
104
+ const until = Number(soft.until ?? 0);
105
+ const n = Number(soft.n ?? 0);
106
+ if (!until || until <= Date.now()) return { waiting: false, until: 0, minutes: 0, n, reason: '' };
107
+ return {
108
+ waiting: true,
109
+ until,
110
+ minutes: Math.max(1, Math.ceil((until - Date.now()) / 60000)),
111
+ n,
112
+ reason: String(soft.reason ?? '-509'),
113
+ };
114
+ }
115
+
89
116
  /**
90
117
  * 公开动作总闸。userInitiated 是你亲手点的:只做防连点,不再按节奏拦。
91
118
  *
@@ -132,6 +159,20 @@ export class Policy {
132
159
  // 永远撞 90 秒的 TOO_FAST,表现就是每次评论都要等一两分钟
133
160
  // 自己出门 minGapSeconds(默认 20 秒)
134
161
  const asked = userInitiated || this.ownerActive();
162
+ /**
163
+ * 刚被 -509/429 挡过:她自己先歇几分钟,别继续撞(撞得越勤退避越长)。
164
+ * 只拦「她自己出门」的动作:主人当场点的(asked)、回主人私信的(free)照旧——
165
+ * 以前这里一刀切,结果一次限流连主人叫她做的事都做不了。
166
+ */
167
+ const wait = this.rateWait();
168
+ if (wait.waiting && !asked && !free) {
169
+ return {
170
+ ok: false,
171
+ code: 'SOFT_WAIT',
172
+ message: `刚被平台限流(${wait.reason}),歇 ${wait.minutes} 分钟再动。`,
173
+ until: wait.until,
174
+ };
175
+ }
135
176
  const minGap = Math.max(5, Number(this.cfg.minGapSeconds ?? 20));
136
177
  const sincePlat = Date.now() - Number((this.store.data.lastWriteByPlat ?? {})[platform] ?? 0);
137
178
  const sinceAny = Date.now() - Number(this.store.data.lastWriteAt ?? 0);
@@ -222,6 +263,22 @@ export class Policy {
222
263
  const guard = this.guard();
223
264
  const before = Number(this.store.data.risk.strikes ?? 0);
224
265
  const strikes = rate ? before + 1 : before;
266
+ // 限流退避(和 riskGuard 无关):-509 只是「太快了」,几分钟自己就好,
267
+ // 但一路硬撞会越来越久——按次数 2/5/10/20/30 分钟递增。
268
+ let soft = null;
269
+ if (rate) {
270
+ const prev = this.store.data.softBackoff ?? {};
271
+ const fresh = Date.now() - Number(prev.at ?? 0) > RATE_RESET_MS;
272
+ const n = fresh ? 1 : Math.max(1, Number(prev.n ?? 0) + 1);
273
+ const minutes = RATE_BACKOFF_MIN[Math.min(n, RATE_BACKOFF_MIN.length) - 1];
274
+ this.store.data.softBackoff = {
275
+ n,
276
+ at: Date.now(),
277
+ until: Date.now() + minutes * 60 * 1000,
278
+ reason: String(message ?? code ?? '-509').slice(0, 120),
279
+ };
280
+ soft = this.rateWait();
281
+ }
225
282
 
226
283
  let hours = 0;
227
284
  if (guard === 'strict') {
@@ -248,7 +305,7 @@ export class Policy {
248
305
  result: hours > 0 ? 'paused' : 'noted',
249
306
  });
250
307
  this.store.save();
251
- return { until, hours, reason: this.store.data.risk.reason, guard };
308
+ return { until, hours, reason: this.store.data.risk.reason, guard, soft };
252
309
  }
253
310
 
254
311
  /** 给 ENV_PROMPT 的一行摘要。 */
@@ -261,9 +318,10 @@ export class Policy {
261
318
  })
262
319
  .join(' ');
263
320
  const paused = this.paused();
321
+ const wait = this.rateWait();
264
322
  const risk = paused.paused ? `停到 ${new Date(paused.until).toLocaleString('zh-CN')}` : '正常';
265
323
  const approval = { confirm: '全批准', auto_low_risk: '赞自动', auto_all: '按额度', dry_run: '只读' }[this.cfg.approval] ?? this.cfg.approval;
266
- return `今日 ${parts} · 风险 ${risk} · 审批 ${approval}`;
324
+ return `今日 ${parts} · 风险 ${risk}${wait.waiting ? ` · 限流歇 ${wait.minutes} 分钟` : ''} · 审批 ${approval}`;
267
325
  }
268
326
 
269
327
  /** 给面板的完整计数。 */
@@ -281,6 +339,7 @@ export class Policy {
281
339
  caps,
282
340
  quiet: this.isQuiet(),
283
341
  paused: this.paused(),
342
+ rateWait: this.rateWait(),
284
343
  ramp: this.rampFactor(),
285
344
  approval: this.cfg.approval,
286
345
  summary: this.summary(),
package/src/store.js CHANGED
@@ -18,6 +18,8 @@ function blank() {
18
18
  actions: [],
19
19
  drafts: [],
20
20
  risk: { until: 0, reason: '', strikes: 0, lastCode: '' },
21
+ /** 限流退避(-509/429):只拦她自己出门,不拦主人点的事。 */
22
+ softBackoff: { until: 0, at: 0, n: 0, reason: '' },
21
23
  lastWriteAt: 0,
22
24
  /** 每个平台各自的上次公开动作时间:抖音限流不该连带卡住 B站。 */
23
25
  lastWriteByPlat: {},
@@ -50,6 +52,7 @@ export class Store {
50
52
  const parsed = JSON.parse(raw);
51
53
  this.data = { ...blank(), ...parsed };
52
54
  this.data.risk = { ...blank().risk, ...(parsed.risk ?? {}) };
55
+ this.data.softBackoff = { ...blank().softBackoff, ...(parsed.softBackoff ?? {}) };
53
56
  this.data.stats = { ...blank().stats, ...(parsed.stats ?? {}) };
54
57
  for (const d of this.data.drafts) {
55
58
  if (d.status === 'sending') {
@@ -211,6 +214,8 @@ export class Store {
211
214
 
212
215
  clearRisk() {
213
216
  this.data.risk = { until: 0, reason: '', strikes: 0, lastCode: '' };
217
+ // 限流退避一起清:主人都点「继续」了,别再自己歇着
218
+ this.data.softBackoff = { until: 0, at: 0, n: 0, reason: '' };
214
219
  }
215
220
 
216
221
  noteInterest(tags) {
package/src/world.js CHANGED
@@ -46,6 +46,9 @@ const ACTION_NAMES = {
46
46
 
47
47
  const PLATFORM_NAMES = { bilibili: 'B站', douyin: '抖音' };
48
48
 
49
+ /** 额度模式里给主人看的短名:出门推送和面板上的「还差 赞 17、评 3」用它。 */
50
+ const QUOTA_SHORT = { like: '赞', comment: '评', follow: '关注', post: '动态', save: '稍后再看' };
51
+
49
52
  /**
50
53
  * 别人发来的内容统一加边界。
51
54
  *
@@ -369,6 +372,27 @@ export class StrollWorld {
369
372
  this.log?.info?.('stroll migrate stranger pending', { before: list.length, after: next.length });
370
373
  }
371
374
 
375
+ /**
376
+ * 一次性迁移:2.0 以前用「每天自己出门 = 0」表示不出去。
377
+ * 2.1 起这件事交给「出门方式」,这里把老配置翻译过去,之后主人改的就不动了:
378
+ * 0 → 不自己出门;正数 → 按次数(和原来完全一样)。
379
+ */
380
+ migrateOutingMode() {
381
+ if (String(this.cfg.outingMode ?? '').trim()) return;
382
+ const mode = Number(this.cfg.autoSessionsPerDay ?? 5) > 0 ? 'sessions' : 'off';
383
+ try {
384
+ this.cfg.outingMode = mode;
385
+ } catch {
386
+ // 宿主冻结了配置对象:outingMode() 会自己按次数推断,不影响这一版的行为
387
+ }
388
+ try {
389
+ this.persist({ outingMode: mode });
390
+ } catch {
391
+ // 宿主不让写就只影响这次进程,面板里还能手改
392
+ }
393
+ this.log?.info?.('stroll migrate outing mode', { to: mode, autoSessionsPerDay: this.cfg.autoSessionsPerDay });
394
+ }
395
+
372
396
  /**
373
397
  * 一次性迁移:旧版本里 `dailyDms` 有个值(默认 3),但那时候私信一律 free,
374
398
  * 这个数从来没生效过,主人也没在界面上见过。1.25.0 起它真的开始限额了,
@@ -377,6 +401,7 @@ export class StrollWorld {
377
401
  migrateConfig() {
378
402
  this.migrateStrangerKeys();
379
403
  this.migrateStrangerSeenEcho();
404
+ this.migrateOutingMode();
380
405
  this.refundDmLoopQuota();
381
406
  const done = this.store.data.migrated ?? {};
382
407
  if (done.dmQuota) return;
@@ -413,20 +438,42 @@ export class StrollWorld {
413
438
  if (!this.inOutingWindow(hour)) return;
414
439
  if (this.policy.isQuiet() || this.policy.paused().paused) return;
415
440
  const day = dayKey(new Date(now), this.timeZone);
416
- // 勾了「必做」但今天还没做够的项:出门要优先补,晚上到点还要单独催
441
+ // 勾了「必做」但今天还没做够的项:按额度模式下决定要不要出门,按次数模式下只决定先补哪个
417
442
  const pending = this.mustDoList().filter((x) => x.left > 0);
418
443
  await this.maybeMustNudge(pending, hour, day);
419
- if (this.cfg.autoSessionsPerDay <= 0) return;
444
+ const mode = this.outingMode();
445
+ if (mode === 'off') return;
446
+ // 刚被 -509/429 挡过:她先歇几分钟再出门(主人当场点的事不受影响)
447
+ if (this.policy.rateWait().waiting) return;
420
448
  const used = Number(this.store.data.autoSessions[day] ?? 0);
449
+ // token 预算是两种模式共用的天花板:每出门一次大约 1500 token
421
450
  const budgetCap = Math.max(0, Math.floor(Number(this.cfg.tokenDailyBudget ?? DEFAULTS.tokenDailyBudget) / 1500));
422
- const cap = Math.min(Number(this.cfg.autoSessionsPerDay), budgetCap);
423
- if (used >= cap) return;
451
+ if (used >= budgetCap) return;
452
+ let gap;
453
+ if (mode === 'quota') {
454
+ // 按额度:出去几次不限,目标做满才收工(私信/群聊不算——没人找时永远做不满)
455
+ if (this.quotaLeftForOuting() <= 0) return;
456
+ /**
457
+ * 还堆着一批草稿等你点头:先别出去。
458
+ * 「等我批准」模式下草稿不涨额度,不拦的话她会一趟趟出去、草稿越堆越多。
459
+ */
460
+ if (this.store.pendingDrafts().length >= 8) return;
461
+ gap = this.autoGapMs();
462
+ } else {
463
+ // 按次数:次数用完就收工;额度仍然拦每个动作
464
+ if (used >= Math.min(Number(this.cfg.autoSessionsPerDay ?? 0), budgetCap)) return;
465
+ /**
466
+ * 节奏只看次数,**不看必做**:必做在按次数模式下只决定「先补哪个」,
467
+ * 不压缩间隔、也不取消 40% 的随机跳过。
468
+ * 以前压到 30 分钟,等于切到按次数后还得把必做一个个关掉才省得下 token。
469
+ */
470
+ gap = this.autoGapMs();
471
+ }
424
472
  // 用 lastAutoAt 而不是 lastSessionAt:主人手动让她看东西,不该把自动出门往后推
425
473
  const lastAuto = Number(this.store.data.lastAutoAt ?? this.store.data.lastSessionAt ?? 0);
426
- // 有必做没完成:间隔压到最多 30 分钟,而且不再掷骰子跳过
427
- const gap = pending.length ? Math.min(this.autoGapMs(), 30 * 60 * 1000) : this.autoGapMs();
428
474
  if (Date.now() - lastAuto < gap) return;
429
- if (!pending.length && Math.random() > 0.6) return;
475
+ // 按次数保留 40% 跳过(像人一样不是每次都去);按额度是「有活就干」,不掷骰子
476
+ if (mode === 'sessions' && Math.random() > 0.6) return;
430
477
  this.store.data.lastAutoAt = Date.now();
431
478
  this.store.data.autoSessions[day] = used + 1;
432
479
  // 记住「这是一次自己出门」:出门窗口内她连续做动作要按 20 秒节奏,
@@ -434,17 +481,103 @@ export class StrollWorld {
434
481
  this.autoWalkAt = Date.now();
435
482
  this.store.save();
436
483
  const mustLine = pending.length
437
- ? `\n今天是必做日,还差:${pending.map((x) => `${x.label} ${x.left}`).join('、')}。先补这些,别只刷着看。`
484
+ ? `\n今天是必做日,还差:${[...pending]
485
+ .sort((a, b) => b.left - a.left)
486
+ .map((x) => `${x.label} ${x.left}`)
487
+ .join('、')}。先补差得多的(一次出门做不完就挑差距最大的),别只刷着看。`
438
488
  : '';
489
+ const quotaLine =
490
+ mode === 'quota'
491
+ ? `\n现在是「按额度」出门:还差 ${this.quotaLeftList()
492
+ .filter((x) => x.left > 0)
493
+ .map((x) => `${x.label} ${x.left}`)
494
+ .join('、')},做满就收工(私信和群聊没人找你时不算没做完)。`
495
+ : '';
439
496
  const maxItems = clamp(Number(this.cfg.sessionMaxItems ?? 8), 3, 12);
440
497
  await this.push(
441
498
  'stroll.due',
442
- `该出门溜达了。今天还剩:${this.policy.summary()}。${mustLine}\n这次最多看 ${maxItems} 条,看完就回来。\n想逛就调 stroll action=brief(一次拿全,别连续 status/feed/inbox);不想去就回「今天不逛」。`,
443
- { day, used: used + 1, must: pending.length > 0 },
499
+ `该出门溜达了。今天还剩:${this.policy.summary()}。${mustLine}${quotaLine}\n这次最多看 ${maxItems} 条,看完就回来。\n想逛就调 stroll action=brief(一次拿全,别连续 status/feed/inbox);不想去就回「今天不逛」。`,
500
+ { day, used: used + 1, must: pending.length > 0, mode },
444
501
  'debounce',
445
502
  );
446
503
  }
447
504
 
505
+ /**
506
+ * 出门方式:
507
+ * off = 不自己出门(只在你叫的时候)
508
+ * sessions = 按次数(默认,和 2.0 一模一样)
509
+ * quota = 按额度(次数不限,主动额度做满才收工)
510
+ * 没填这个键(老配置)时按「次数」理解;次数填 0 一律当作不出去。
511
+ */
512
+ outingMode() {
513
+ const raw = String(this.cfg.outingMode ?? '').toLowerCase();
514
+ if (raw === 'off') return 'off';
515
+ if (raw === 'quota') return 'quota';
516
+ return Number(this.cfg.autoSessionsPerDay ?? 0) > 0 ? 'sessions' : 'off';
517
+ }
518
+
519
+ /**
520
+ * 额度模式追哪些项:**勾了必做的**才算目标;一个都没勾就退回全部主动额度。
521
+ * 私信、群聊永远不算——没人找她的时候这两项做不满,算进去就变成一直出门却没事干。
522
+ */
523
+ quotaTargetKeys() {
524
+ const wanted = [
525
+ ['like', 'mustLike'],
526
+ ['comment', 'mustComment'],
527
+ ['follow', 'mustFollow'],
528
+ ['post', 'mustPost'],
529
+ ]
530
+ .filter(([, flag]) => this.cfg[flag] === true)
531
+ .map(([key]) => key)
532
+ .filter((key) => this.canAct(key));
533
+ if (wanted.length) return wanted;
534
+ // 没勾必做(或者勾了但都做不了):退回全部主动额度,不然额度模式会永远不出门
535
+ return ['like', 'comment', 'follow', 'post', 'save'].filter((key) => this.canAct(key));
536
+ }
537
+
538
+ /** 这个动作现在还有没有可能做成:开关关掉、只读模式就不算目标。 */
539
+ canAct(key) {
540
+ if (this.cfg.approval === 'dry_run') return false;
541
+ if (key === 'follow') {
542
+ if (!this.cfg.allowFollow) return false;
543
+ // 两个平台都关注不了才算做不了:B站关掉、抖音关注方式也关掉
544
+ const bili = this.cfg.bilibili !== false;
545
+ const douyin = this.cfg.douyin !== false && String(this.cfg.douyinFollowMode ?? 'browser') !== 'off';
546
+ if (!bili && !douyin) return false;
547
+ }
548
+ if (key === 'post' && !this.cfg.allowPost) return false;
549
+ if (key === 'save' && !this.cfg.allowSave) return false;
550
+ return true;
551
+ }
552
+
553
+ /** 额度模式的进度(只算目标项)。 */
554
+ quotaProgress() {
555
+ let left = 0;
556
+ let total = 0;
557
+ const keys = this.quotaTargetKeys();
558
+ for (const key of keys) {
559
+ const r = this.policy.remaining(key);
560
+ left += Math.max(0, Math.min(r.daily, r.weekly));
561
+ total += Math.max(0, Math.min(r.dailyCap, r.weeklyCap));
562
+ }
563
+ return { left, total, done: Math.max(0, total - left), keys };
564
+ }
565
+
566
+ /** 额度模式还差哪几项(差距从大到小),给出门推送和面板用。 */
567
+ quotaLeftList() {
568
+ return this.quotaTargetKeys()
569
+ .map((key) => {
570
+ const r = this.policy.remaining(key);
571
+ return { key, label: QUOTA_SHORT[key] ?? key, left: Math.max(0, Math.min(r.daily, r.weekly)) };
572
+ })
573
+ .sort((a, b) => b.left - a.left);
574
+ }
575
+
576
+ /** 额度模式还能不能出门:目标项只要还剩一个动作就还能出去。 */
577
+ quotaLeftForOuting() {
578
+ return this.quotaProgress().left;
579
+ }
580
+
448
581
  /**
449
582
  * 今天的「必做」清单:勾了开关的项,目标次数就是对应的每日额度。
450
583
  * 用 usage 里的今日计数(unlike→like、reply→comment、unfollow→follow 已经在 capAction 里归一)。
@@ -470,9 +603,22 @@ export class StrollWorld {
470
603
  .filter((x) => x.need > 0);
471
604
  }
472
605
 
473
- /** 晚上还没做完必做:单独催她,最多 3 次、每次间隔 30 分钟。 */
606
+ /** 晚上还没做完必做:单独催她,最多 3 次、每次间隔 30 分钟。**只在按额度模式下催**。 */
474
607
  async maybeMustNudge(pending, hour, day) {
475
608
  if (!pending?.length) return;
609
+ /**
610
+ * 按次数 / 不自己出门都不催。
611
+ * 按次数模式的语义就是「出去几次就收工」,次数用完之后再把她叫出去补必做,
612
+ * 等于逼主人切模式时顺手把必做一个个关掉——那不该是主人的活儿。
613
+ */
614
+ if (this.outingMode() !== 'quota') return;
615
+ /**
616
+ * 只催「额度模式真正在追的项」。
617
+ * 私信/群聊(以及被关掉的动作)不在目标里,催了也没用——没人找她的时候做不完。
618
+ */
619
+ const targets = new Set(this.quotaTargetKeys());
620
+ const list = pending.filter((x) => targets.has(x.action));
621
+ if (!list.length) return;
476
622
  // 原来写死「18 点后催」;窗口换成凌晨就永远催不到。
477
623
  // 改成「离窗口结束不到 4 小时」才开始催。
478
624
  const { to } = this.outingWindow();
@@ -493,7 +639,7 @@ export class StrollWorld {
493
639
  this.store.save();
494
640
  await this.push(
495
641
  'stroll.due',
496
- `今天还有必做没完成:${pending.map((x) => `${x.label} ${x.done}/${x.need}`).join('、')}。现在就去补,不用等下一次出门;补完再回我一句。`,
642
+ `今天还有必做没完成:${list.map((x) => `${x.label} ${x.done}/${x.need}`).join('、')}。现在就去补,不用等下一次出门;补完再回我一句。`,
497
643
  { must: true, day },
498
644
  'flush',
499
645
  );
@@ -3044,7 +3190,10 @@ export class StrollWorld {
3044
3190
  pending: this.store.pendingDrafts().map((d) => ({ id: d.id, action: d.action, platform: d.platform, preview: this.previewText(d) })),
3045
3191
  last: this.store.lastActions(3),
3046
3192
  interests: this.store.topInterests(6),
3193
+ // 出门方式(off/sessions/quota)和「按额度」还剩多少个主动动作
3194
+ outingMode: this.outingMode(),
3047
3195
  autoSessions: this.store.data.autoSessions[dayKey()] ?? 0,
3196
+ quotaLeft: this.quotaLeftForOuting(),
3048
3197
  // 必做清单(勾了开关的项):她出门前先看还差多少
3049
3198
  mustDo: this.mustDoList(),
3050
3199
  };
@@ -3099,6 +3248,10 @@ export class StrollWorld {
3099
3248
  policy: status.policy.summary,
3100
3249
  pending: status.pending.length,
3101
3250
  owner: status.owner,
3251
+ // 出门方式:sessions 按次数 / quota 按额度(quotaLeft = 主动动作还剩多少个)/ off 不自己出门
3252
+ outingMode: status.outingMode,
3253
+ autoSessions: status.autoSessions,
3254
+ quotaLeft: status.quotaLeft,
3102
3255
  // 必做清单要跟着 brief 一起给她,不然出门推送里只能看到「还剩几次」却不知道目标
3103
3256
  mustDo: status.mustDo ?? this.mustDoList(),
3104
3257
  },
@@ -4474,7 +4627,7 @@ export class StrollWorld {
4474
4627
  }
4475
4628
  return (
4476
4629
  `「${s}」不在能发的目录里。现在只能发这几个目录里的图:\n${dirs.join('\n')}\n` +
4477
- '想放开别的目录:设置 → 高级与节奏 → 额外允许发图的目录,填绝对路径(逗号分隔)。'
4630
+ '想放开别的目录:设置 → 高级 → 额外允许发图的目录,填绝对路径(逗号分隔)。'
4478
4631
  );
4479
4632
  }
4480
4633
 
@@ -5433,6 +5586,9 @@ export class StrollWorld {
5433
5586
  caps: this.policy.snapshot(),
5434
5587
  quiet: { from: this.cfg.quietFrom, to: this.cfg.quietTo },
5435
5588
  autoSessionsPerDay: this.cfg.autoSessionsPerDay,
5589
+ // 出门方式:sessions 按次数 / quota 按额度 / off 不自己出门
5590
+ outingMode: this.outingMode(),
5591
+ quotaLeft: this.quotaLeftForOuting(),
5436
5592
  interests: this.cfg.interests,
5437
5593
  avoid: this.cfg.avoid,
5438
5594
  tokenDailyBudget: this.cfg.tokenDailyBudget,
@@ -5532,7 +5688,26 @@ export class StrollWorld {
5532
5688
  if (method === 'set_auto') {
5533
5689
  const n = clamp(Number(args[0] ?? 0), 0, 10);
5534
5690
  this.persist({ autoSessionsPerDay: n });
5535
- return { ok: true, autoSessionsPerDay: n };
5691
+ // 面板上那个开关只管「开/关」:填 0 等于不自己出门,填正数等于按次数
5692
+ const mode = n > 0 ? 'sessions' : 'off';
5693
+ try {
5694
+ this.cfg.outingMode = mode;
5695
+ } catch {
5696
+ // 配置对象被冻结:下面的 persist 已经把磁盘改了,宿主热更新会接上
5697
+ }
5698
+ this.persist({ outingMode: mode });
5699
+ return { ok: true, autoSessionsPerDay: n, outingMode: mode };
5700
+ }
5701
+ if (method === 'set_outing') {
5702
+ const v = String(args[0] ?? '');
5703
+ if (!['off', 'sessions', 'quota'].includes(v)) return { ok: false, error: '不认识这个出门方式。' };
5704
+ try {
5705
+ this.cfg.outingMode = v;
5706
+ } catch {
5707
+ // 同上
5708
+ }
5709
+ this.persist({ outingMode: v });
5710
+ return { ok: true, outingMode: v };
5536
5711
  }
5537
5712
  if (method === 'set_approval') {
5538
5713
  const v = String(args[0] ?? '');
@@ -5825,22 +6000,52 @@ export class StrollWorld {
5825
6000
  }
5826
6001
  }
5827
6002
 
5828
- /** 下一次自动出门大概什么时候;今天用完或关着就返回 null。 */
6003
+ /** 下一次自动出门大概什么时候;今天收工或关着就返回 null。 */
5829
6004
  autoNextView() {
5830
- const daily = Number(this.cfg.autoSessionsPerDay ?? 0);
5831
- if (daily <= 0 || !this.cfg.enabled) return null;
6005
+ const mode = this.outingMode();
6006
+ if (!this.cfg.enabled || mode === 'off') return null;
5832
6007
  const budgetCap = Math.max(0, Math.floor(Number(this.cfg.tokenDailyBudget ?? DEFAULTS.tokenDailyBudget) / 1500));
5833
- const cap = Math.min(daily, budgetCap);
5834
6008
  const day = dayKey(new Date(), this.timeZone);
5835
6009
  const used = Number(this.store.data.autoSessions[day] ?? 0);
5836
- if (used >= cap) return { done: true, used, cap };
6010
+ const quota = this.quotaProgress();
6011
+ const daily = Math.max(0, Math.floor(Number(this.cfg.autoSessionsPerDay ?? 0)));
6012
+ // 按额度模式不看「每天自己出门」;按次数模式才看
6013
+ const cap = mode === 'quota' ? budgetCap : Math.min(daily, budgetCap);
6014
+ const base = {
6015
+ mode,
6016
+ used,
6017
+ cap,
6018
+ quotaLeft: quota.left,
6019
+ quotaTotal: quota.total,
6020
+ // 还差哪几项(只剩没做满的):面板和推送都直接照这个念,别只说一个总数
6021
+ targets: mode === 'quota' ? this.quotaLeftList().filter((x) => x.left > 0) : [],
6022
+ drafts: this.store.pendingDrafts().length,
6023
+ budgetLeft: Math.max(0, budgetCap - used),
6024
+ };
6025
+ // token 预算是两种模式共用的天花板
6026
+ if (used >= budgetCap) return { ...base, done: true, reason: 'budget' };
6027
+ // 按额度但一个能做的动作都没有(只读模式、公开动作全关):不出门,别空跑
6028
+ if (mode === 'quota' && !quota.keys.length) return { ...base, done: true, reason: 'none' };
6029
+ // 按额度但草稿堆着没人批:先等你批完再出去
6030
+ if (mode === 'quota' && this.store.pendingDrafts().length >= 8) return { ...base, done: true, reason: 'drafts' };
6031
+ // 按额度:目标做满 = 今天收工
6032
+ if (mode === 'quota' && quota.left <= 0) return { ...base, done: true, reason: 'quota' };
6033
+ // 按次数:次数用完 = 今天收工
6034
+ if (mode === 'sessions' && used >= cap) return { ...base, done: true, reason: 'sessions' };
6035
+
5837
6036
  const last = Number(this.store.data.lastAutoAt ?? this.store.data.lastSessionAt ?? 0);
5838
- // tick 里有 40% 概率跳过(Math.random() > 0.6 才出门),按期望 1/0.6 折算,
6037
+ // 两种模式都按正常间隔走:必做不影响节奏(按额度里它决定的是「还出不出门」)
6038
+ const gapMs = this.autoGapMs();
6039
+ // tick 里有 40% 概率跳过(只发生在按次数模式下),按期望 1/0.6 折算,
5839
6040
  // 不然显示的时间永远比实际早一截
5840
- const gap = this.autoGapMs() / 0.6;
5841
- let at = last ? last + gap : Date.now() + 90 * 1000;
6041
+ const skip = mode === 'sessions';
6042
+ const expect = skip ? gapMs / 0.6 : gapMs;
6043
+ let at = last ? last + expect : Date.now() + 90 * 1000;
5842
6044
  at = this.nextOutingWindow(Math.max(at, Date.now()));
5843
- return { done: false, used, cap, gapMinutes: Math.round(this.autoGapMs() / 60000), at };
6045
+ // 限流退避中:把「下次」推到歇完之后,不然面板显示「快出门了」却动不了
6046
+ const wait = this.policy.rateWait();
6047
+ if (wait.waiting) at = Math.max(at, wait.until);
6048
+ return { ...base, done: false, at, gapMinutes: Math.round(gapMs / 60000) };
5844
6049
  }
5845
6050
 
5846
6051
  /** 自动出门只在 9:00-23:00;落在窗外就推到下一个 9:00。 */
@@ -6015,6 +6220,7 @@ export class StrollWorld {
6015
6220
  background: this.cfg.background !== false,
6016
6221
  approval: this.cfg.approval,
6017
6222
  autoSessionsPerDay: Number(this.cfg.autoSessionsPerDay ?? 0),
6223
+ outingMode: this.outingMode(),
6018
6224
  outingFrom: Number(this.cfg.outingFrom ?? 9),
6019
6225
  outingTo: Number(this.cfg.outingTo ?? 23),
6020
6226
  dailyLikes: Number(this.cfg.dailyLikes ?? 0),
package/webui/client.js CHANGED
@@ -738,8 +738,9 @@ export default {
738
738
  let liveWatch = null;
739
739
  let toastTimer = null;
740
740
  let lastPresenceKey = '';
741
- // 关掉「自己出门」再打开时,恢复上次的次数,而不是硬塞成 1 次
741
+ // 关掉「自己出门」再打开时,恢复上次的次数/方式,而不是硬塞成 1 次
742
742
  let autoRemember = 5;
743
+ let autoRememberMode = 'sessions';
743
744
 
744
745
  function msgSet(text, bad = false) {
745
746
  msg.textContent = text || '';
@@ -838,7 +839,15 @@ export default {
838
839
  const rawComment = Number(cfg.dailyComments ?? caps.comment);
839
840
  const rawAuto = Number(cfg.autoSessionsPerDay ?? 0);
840
841
  const rawGap = Number(cfg.autoGapMinutes ?? 90);
841
- const tail = rawAuto > 0 ? ` · 自己出门 ${rawAuto} 次(每 ${rawGap} 分钟一次)` : ' · 不自己出门';
842
+ const rawMode = String(cfg.outingMode ?? 'sessions');
843
+ const tail =
844
+ rawMode === 'off'
845
+ ? ' · 不自己出门'
846
+ : rawMode === 'quota'
847
+ ? ` · 按额度出门(每 ${rawGap} 分钟一次,做满就停)`
848
+ : rawAuto > 0
849
+ ? ` · 自己出门 ${rawAuto} 次(每 ${rawGap} 分钟一次)`
850
+ : ' · 不自己出门';
842
851
  activityNote.textContent =
843
852
  activityKey === 'custom'
844
853
  ? `自定义:每天 ${rawLike} 赞 / ${rawComment} 评${tail}`
@@ -894,24 +903,62 @@ export default {
894
903
  switchTrack.classList.toggle('on', bgOn);
895
904
  switchLabel.textContent = bgOn ? '后台模式' : '开窗模式';
896
905
  browseSwitch.setAttribute('aria-checked', String(bgOn));
906
+ const outingMode = String(cfg.outingMode ?? 'sessions');
907
+ if (outingMode !== 'off') autoRememberMode = outingMode;
908
+ const autoOn = outingMode !== 'off';
897
909
  const autoCap = Number(cfg.autoSessionsPerDay ?? 0);
898
910
  if (autoCap > 0) autoRemember = autoCap;
899
- autoTrack.classList.toggle('on', autoCap > 0);
900
- autoSwitch.setAttribute('aria-checked', String(autoCap > 0));
911
+ autoTrack.classList.toggle('on', autoOn);
912
+ autoSwitch.setAttribute('aria-checked', String(autoOn));
901
913
  const autoNext = data?.autoNext;
902
- let autoText = autoCap > 0 ? `每天自己逛 ${autoCap} 次` : '关着,只在你叫的时候';
903
- if (autoCap > 0) {
904
- if (autoNext?.done) autoText += ' · 今天够了';
905
- else if (autoNext?.at) {
906
- const remainMs = Number(autoNext.at) - Date.now();
907
- autoText += remainMs > 5 * 60 * 1000 ? ` · 下次约 ${hhmm(Number(autoNext.at))}` : ' · 快出门了';
914
+ const autoUsed = Number(data?.autoSessions ?? 0);
915
+ const quotaLeft = Number(autoNext?.quotaLeft ?? 0);
916
+ const quotaTotal = Number(autoNext?.quotaTotal ?? 0);
917
+ const quotaDone = Math.max(0, quotaTotal - quotaLeft);
918
+ let autoText = '关着,只在你叫的时候';
919
+ if (autoOn) {
920
+ if (outingMode === 'quota') {
921
+ // 按额度:目标项 = 勾了必做的那几项(没勾就按全部主动额度)
922
+ const targets = Array.isArray(autoNext?.targets) ? autoNext.targets : [];
923
+ const pendingText = targets.map((t) => `${t.label} ${t.left}`).join('、');
924
+ if (autoNext?.done && autoNext?.reason === 'budget') {
925
+ autoText = `按额度 · token 预算用完了(还差 ${pendingText || `${quotaLeft} 个`})`;
926
+ } else if (autoNext?.done && autoNext?.reason === 'none') {
927
+ autoText = '按额度 · 没有能做的动作(看「公开动作」和是不是只读模式)';
928
+ } else if (autoNext?.done && autoNext?.reason === 'drafts') {
929
+ autoText = `按额度 · 还有 ${Number(autoNext?.drafts ?? 0)} 条草稿等你批,先不出门`;
930
+ } else if (quotaLeft <= 0) {
931
+ autoText = '按额度 · 目标做完了,今天收工';
932
+ } else {
933
+ autoText = `按额度 · 还差 ${pendingText || `${quotaLeft} 个`}`;
934
+ if (autoNext?.at) {
935
+ const remainMs = Number(autoNext.at) - Date.now();
936
+ autoText += remainMs > 5 * 60 * 1000 ? ` · 下次约 ${hhmm(Number(autoNext.at))}` : ' · 快出门了';
937
+ }
938
+ }
939
+ } else {
940
+ autoText = autoCap > 0 ? `按次数 · 每天自己逛 ${autoCap} 次` : '按次数 · 次数是 0,不会自己出门';
941
+ const effCap = Number(autoNext?.cap ?? autoCap);
942
+ if (autoCap > 0 && effCap < autoCap) autoText += `(token 预算只够 ${effCap} 次)`;
943
+ if (autoCap > 0) {
944
+ if (autoNext?.done) autoText += ' · 今天够了';
945
+ else if (autoNext?.at) {
946
+ const remainMs = Number(autoNext.at) - Date.now();
947
+ autoText += remainMs > 5 * 60 * 1000 ? ` · 下次约 ${hhmm(Number(autoNext.at))}` : ' · 快出门了';
948
+ }
949
+ }
908
950
  }
951
+ // 撞了 -509 会自己歇几分钟:面板上说清楚,别让主人以为她卡住了
952
+ const rateWait = p?.caps?.rateWait;
953
+ if (rateWait?.waiting) autoText += ` · 限流歇 ${Number(rateWait.minutes ?? 0)} 分钟`;
909
954
  }
910
955
  autoLabel.textContent = autoText;
911
956
  statReads.set(Number(data?.todayReads ?? 0), ' 条');
957
+ // 按次数看「出门几次」;按额度看「额度做了多少」,两边的分母不是一个东西
958
+ const autoShownCap = Number(autoNext?.cap ?? autoCap);
912
959
  statAuto.set(
913
- autoCap > 0 ? `${Number(data?.autoSessions ?? 0)}/${autoCap}` : String(Number(data?.autoSessions ?? 0)),
914
- autoCap > 0 ? ' 次' : ' 次 · 关着',
960
+ autoOn && outingMode === 'sessions' ? `${autoUsed}/${autoShownCap}` : String(autoUsed),
961
+ !autoOn ? ' 次 · 关着' : outingMode === 'quota' ? ` 次 · 额度 ${quotaDone}/${quotaTotal}` : ' 次',
915
962
  );
916
963
  // 这是「预算」(配置值),不是已用量;原来 Math.round(8600/1000) 会显示成 9k,看着像超了
917
964
  const budgetK = (Number(cfg.tokenDailyBudget ?? 0) / 1000).toFixed(1).replace(/\.0$/, '');
@@ -2211,16 +2258,23 @@ export default {
2211
2258
  }
2212
2259
 
2213
2260
  async function toggleAuto() {
2214
- const cur = Number(lastData?.config?.autoSessionsPerDay ?? 0);
2215
- const next = cur > 0 ? 0 : autoRemember > 0 ? autoRemember : 5;
2216
- msgSet(cur > 0 ? '正在关掉自己出门…' : '正在开启自己出门…');
2261
+ const cur = String(lastData?.config?.outingMode ?? 'sessions');
2262
+ const turningOn = cur === 'off';
2263
+ const next = turningOn ? autoRememberMode || 'sessions' : 'off';
2264
+ msgSet(turningOn ? '正在开启自己出门…' : '正在关掉自己出门…');
2217
2265
  try {
2218
- const result = await ctx.invoke('set_auto', [next]);
2266
+ // 按次数但次数是 0:顺手把次数填回去,不然开了等于没开
2267
+ const needCount = turningOn && next === 'sessions' && Number(lastData?.config?.autoSessionsPerDay ?? 0) <= 0;
2268
+ const result = needCount
2269
+ ? await ctx.invoke('set_auto', [autoRemember > 0 ? autoRemember : 5])
2270
+ : await ctx.invoke('set_outing', [next]);
2219
2271
  if (result?.ok === false) throw new Error(result.error || '没改成');
2220
2272
  msgSet(
2221
- cur > 0
2273
+ !turningOn
2222
2274
  ? '已关掉自己出门,只在你叫的时候逛。'
2223
- : `已开启:每天最多自己逛 ${next} 次,两次之间会隔一会儿。`,
2275
+ : next === 'quota'
2276
+ ? '已开启:按额度出门,主动额度做满才收工。'
2277
+ : `已开启:每天最多自己逛 ${Number(lastData?.config?.autoSessionsPerDay ?? 5)} 次,两次之间会隔一会儿。`,
2224
2278
  );
2225
2279
  await refresh();
2226
2280
  } catch (error) {