dsh-deepmoyu 2.7.1 → 2.8.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.
Files changed (59) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +10 -3
  5. package/SECURITY.md +4 -0
  6. package/client.js +509 -296
  7. package/docs/ACTIVITY_TUNING.md +50 -0
  8. package/docs/CONTINUOUS_BROWSING.md +35 -0
  9. package/docs/FUN_FEATURE_REFERENCES.md +128 -0
  10. package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
  11. package/docs/NATURAL_AUTONOMY.md +91 -0
  12. package/docs/PRIVACY.md +7 -0
  13. package/docs/RELEASE_2_8_0.md +27 -0
  14. package/docs/RELEASE_AUDIT.md +16 -2
  15. package/docs/RELEASING.md +15 -0
  16. package/docs/TEST_MATRIX.md +9 -0
  17. package/docs/WORKFLOW_REFERENCES.md +57 -0
  18. package/docs/tool-evidence.json +6 -1
  19. package/index.js +941 -268
  20. package/lib/asr.py +22 -0
  21. package/lib/background-tools.mjs +1 -1
  22. package/lib/browse-preferences.mjs +154 -0
  23. package/lib/browse-progress.mjs +165 -0
  24. package/lib/comment.mjs +29 -46
  25. package/lib/config-policy.mjs +2 -1
  26. package/lib/curiosity.mjs +153 -0
  27. package/lib/favops.py +38 -6
  28. package/lib/learn-review.mjs +13 -3
  29. package/lib/learn-store.mjs +7 -0
  30. package/lib/learn.mjs +234 -69
  31. package/lib/login.mjs +20 -3
  32. package/lib/mcp.mjs +5 -2
  33. package/lib/memory-center.mjs +21 -19
  34. package/lib/mirror.mjs +15 -1
  35. package/lib/mode.mjs +23 -9
  36. package/lib/model-relay-adapter.mjs +1 -1
  37. package/lib/model-relay.mjs +21 -4
  38. package/lib/pace.mjs +15 -7
  39. package/lib/persona.mjs +41 -30
  40. package/lib/pet-session.mjs +9 -3
  41. package/lib/product-status.mjs +38 -2
  42. package/lib/prompts.mjs +38 -37
  43. package/lib/pv-session.mjs +16 -1
  44. package/lib/pv.mjs +18 -3
  45. package/lib/pv.py +168 -30
  46. package/lib/pv_cover.py +11 -1
  47. package/lib/pv_editorial.py +28 -4
  48. package/lib/pv_plan.py +20 -2
  49. package/lib/pv_verify.py +14 -0
  50. package/lib/session-continuation.mjs +47 -0
  51. package/lib/state.mjs +6 -2
  52. package/lib/sticker-automation.mjs +16 -2
  53. package/lib/sticker.mjs +4 -1
  54. package/lib/uvfetch.mjs +38 -12
  55. package/package.json +1 -1
  56. package/presets/deepmoyu.patch.yml +8 -10
  57. package/prompts/duty.md +30 -38
  58. package/vendor/bilibili-mcp/mcp_server.py +204 -47
  59. package/vendor/bilibili-mcp/runtime_guard.py +164 -31
package/client.js CHANGED
@@ -69,12 +69,21 @@ window.__ModuleLoader__.load({
69
69
  error.sessionId = detail?.sessionId
70
70
  throw error
71
71
  }
72
- const jget = async (p) => asJson(await fetch(API + p, { credentials: 'same-origin' }))
72
+ /* ⏱ P3-38:所有 API 请求带 15s 超时 —— 后端卡死时请求不能永远挂着(挂住的 PATCH
73
+ * 会占住单飞挡板、挂住的轮询会一直 inFlight)。AbortSignal.timeout 在旧宿主上
74
+ * 可能不存在,缺了就退回无超时,行为跟以前一样;超时抛的是 DOMException
75
+ * (name=TimeoutError,没有 .status),各调用方的 catch 原样兜住,释放逻辑不受影响。 */
76
+ const FETCH_TIMEOUT_MS = 15000
77
+ const fetchSignal = () => {
78
+ try { return AbortSignal.timeout(FETCH_TIMEOUT_MS) } catch { return undefined }
79
+ }
80
+ const jget = async (p) => asJson(await fetch(API + p, { credentials: 'same-origin', signal: fetchSignal() }))
73
81
  const jpost = async (p, body) => asJson(await fetch(API + p, {
74
82
  method: 'POST',
75
83
  credentials: 'same-origin',
76
84
  headers: { 'content-type': 'application/json' },
77
85
  body: JSON.stringify(body || {}),
86
+ signal: fetchSignal(),
78
87
  }))
79
88
 
80
89
  const CSS = `
@@ -971,6 +980,16 @@ window.__ModuleLoader__.load({
971
980
  [data-bd-segments]{border-color:#d5e7f5;background:#edf6fd;padding:4px;border-radius:10px;}
972
981
  [data-bd-segments] button{color:#466b84;padding:8px 13px;min-height:34px;}
973
982
  [data-bd-segments] button[aria-pressed="true"]{background:#fff;color:#126a9a;box-shadow:0 1px 5px #1b639516;}
983
+ [data-bd-browse-settings] [data-bd-browse-topic][aria-pressed="false"]{background:transparent;border-color:transparent;box-shadow:none;}
984
+ [data-bd-browse-settings] [data-bd-browse-topic][aria-pressed="true"]{background:#d7edfa;border-color:#79b8db;color:#114e76;font-weight:700;box-shadow:0 1px 3px #1b63951c;}
985
+ [data-bd-browse-settings] > [data-bd-field]{flex:none;}
986
+ [data-bd-browse-common]{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px;align-items:start;}
987
+ [data-bd-browse-common]>[data-bd-field],[data-bd-browse-refine]>div>[data-bd-field]{flex:none;min-width:0;}
988
+ [data-bd-browse-common] textarea{min-height:66px;resize:vertical;}
989
+ [data-bd-pet-preferences],[data-bd-companion-settings],[data-bd-review-controls]{display:flex;flex-direction:column;gap:12px;}
990
+ [data-bd-review-controls]{padding:12px 0 16px;border-bottom:1px solid #d7e8f4;}
991
+ [data-bd-advanced]>summary{display:list-item;cursor:pointer;line-height:1.8;}
992
+ @media(max-width:660px){[data-bd-browse-common]{grid-template-columns:minmax(0,1fr);}}
974
993
  [data-bd-pet-modal] [data-bd-sec]{background:#fff;border-color:#d7e8f4;border-radius:13px;box-shadow:0 3px 12px #24649204;padding:18px;gap:12px;}
975
994
  [data-bd-pet-modal] [data-bd-h] b{color:#2a4d65;}
976
995
  [data-bd-pet-modal] [data-bd-note]{color:#537289;line-height:1.7;}
@@ -995,6 +1014,27 @@ window.__ModuleLoader__.load({
995
1014
  [data-bd-run-details]>summary{cursor:pointer;color:#3b6681;font-size:12px;}
996
1015
  [data-bd-run-details][open]>summary{margin-bottom:14px;}
997
1016
  [data-bd-run-details]>[data-bd-sec]{margin-bottom:14px;}
1017
+ [data-bd-curiosity-journal]{border:1px solid #d8e4e8;border-radius:13px;background:#fffdf8;padding:14px 17px;}
1018
+ [data-bd-curiosity-journal]>summary{cursor:pointer;color:#446879;font-size:12px;line-height:1.8;}
1019
+ [data-bd-journal-title]{font-weight:700;color:#365e76;margin-right:12px;}
1020
+ [data-bd-journal-content],[data-bd-journal-explore],[data-bd-journal-finds]{display:flex;flex-direction:column;gap:13px;min-width:0;}
1021
+ [data-bd-journal-content]{padding-top:16px;}
1022
+ [data-bd-journal-form]{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px;}
1023
+ [data-bd-journal-form] [data-bd-field]{margin:0;flex:1 1 300px;}
1024
+ [data-bd-journal-form] input{width:100%;box-sizing:border-box;}
1025
+ [data-bd-curiosity-id],[data-bd-find-id]{border:1px solid #e6dfd2;border-radius:11px;padding:15px;background:#fffefa;min-width:0;overflow-wrap:anywhere;}
1026
+ [data-bd-curiosity-id] h3{font-size:15px;line-height:1.7;font-weight:600;color:#284f67;margin:10px 0;}
1027
+ [data-bd-curiosity-id] p{margin:0 0 12px;}
1028
+ [data-bd-journal-manage]{flex:none;}
1029
+ [data-bd-journal-manage] summary{font-size:11px;cursor:pointer;color:#5d7482;}
1030
+ [data-bd-journal-manage]>div{margin-top:8px;}
1031
+ [data-bd-journal-eyebrow]{font-size:11px;color:#8e7957;}
1032
+ [data-bd-find-id] blockquote{font-size:17px;line-height:1.9;margin:13px 0 16px;color:#365566;white-space:pre-wrap;}
1033
+ [data-bd-find-id]>a{display:inline-block;margin-bottom:15px;}
1034
+ [data-bd-curiosity-journal] a{color:#137fa5;text-underline-offset:3px;}
1035
+ [data-bd-journal-source]{display:inline-block;margin-bottom:6px;}
1036
+ [data-bd-journal-empty]{padding:22px 15px;color:#6c7f88;line-height:1.8;background:#faf8f2;border-radius:9px;font-size:12px;}
1037
+ @media(max-width:660px){[data-bd-curiosity-journal]{padding:13px 14px;}[data-bd-curiosity-journal]>summary>[data-bd-note]{display:block;margin-left:14px;}[data-bd-journal-content] [data-bd-grow]{min-width:0;}[data-bd-journal-content]>[data-bd-row]{justify-content:space-between;}[data-bd-journal-content]>[data-bd-row]>[data-bd-grow]{display:none;}}
998
1038
  [data-bd-week-recovery]>div:only-child{grid-column:1/-1;}
999
1039
  [data-bd-pet-modal] [data-bd-settings-page="video"] [data-bd-pet-grid]>[data-bd-sec]{background:#fff;border-color:#d7e8f4;box-shadow:0 3px 12px #24649204;}
1000
1040
  [data-bd-pet-modal] [data-bd-video-stage]:not([data-warn]){background:#e8f5ff;color:#216c94;border-color:#bfdff5;}
@@ -1108,6 +1148,41 @@ window.__ModuleLoader__.load({
1108
1148
  return [st, externalSet.current, fails]
1109
1149
  }
1110
1150
 
1151
+ /* 🔐 P2-15 单飞 PATCH 包装:并发 PATCH 响应乱序会把本地 config 回写成旧值(审计 P2-15)——
1152
+ * 例如「制作模型」select 连改两下、两个 PATCH 同时在飞,先发的那笔**后**回来,
1153
+ * patch 成功路径里的 setSt({config: r.config}) 会把界面回写成上一笔的旧值。
1154
+ * 这里保证同一时刻只有一个 /config 在飞:
1155
+ * · 在飞期间新到的改动只记「待发」(pending 按键合并、同键取最新值),界面控件
1156
+ * 照常更新本地显示、调用方拿到 true 不回弹;
1157
+ * · 当前请求结束后若有积压,再补发一次(补发永远带最新值;补发期间又来新值会
1158
+ * 走上面的排队分支,不会出现并发)。
1159
+ * 失败提示仍由 patch 内部的 say() 负责,单飞层不做二次弹错。 */
1160
+ function useLatestPatch(patch) {
1161
+ const patchRef = React.useRef(patch)
1162
+ patchRef.current = patch
1163
+ const inFlight = React.useRef(false)
1164
+ const pending = React.useRef(null) // { p, what }:在飞期间积压的改动(按键合并)
1165
+ const send = async (p, what) => {
1166
+ if (inFlight.current) {
1167
+ const old = pending.current
1168
+ pending.current = old
1169
+ ? { p: { ...old.p, ...p }, what: [old.what, what].filter(Boolean).join('、') }
1170
+ : { p: { ...p }, what }
1171
+ return true
1172
+ }
1173
+ inFlight.current = true
1174
+ try {
1175
+ return await patchRef.current(p, what)
1176
+ } finally {
1177
+ inFlight.current = false
1178
+ const next = pending.current
1179
+ pending.current = null
1180
+ if (next) send(next.p, next.what).catch(() => {})
1181
+ }
1182
+ }
1183
+ return send
1184
+ }
1185
+
1111
1186
  /* 🗑 旧的全页设置 Panel 已退役(2026-10-27):它的配置项早已搬进 PetSettingsModal,
1112
1187
  活动流水 / 出手节奏读数 / 判重账本行也一并搬了进来。回归断言改由 render-modal.cjs 盯弹窗本体。 */
1113
1188
 
@@ -1309,172 +1384,6 @@ window.__ModuleLoader__.load({
1309
1384
  })))
1310
1385
  }
1311
1386
 
1312
- /* 🌙 后台刷一轮…:headless 独立进程那一轮(不占对话)。模型下拉复用 /status 的
1313
- * models 清单(拿不到就退手填,判据与设置页一致);token 预算以「万」为单位
1314
- * 显示与输入,提交时换算成整数。开跑走 POST /pet/start {mode:'background'}。 */
1315
- function PetBgPanel({ st, toast, onDone }) {
1316
- const cfg = (st && st.config) || {}
1317
- const rows = Array.isArray(st && st.models) ? st.models : []
1318
- const [model, setModel] = React.useState(null) // null = 还没从配置初始化过('' 是合法值:默认模型)
1319
- const [wan, setWan] = React.useState(null) // 同上;'' = 不限
1320
- const [busy, setBusy] = React.useState(false)
1321
- const [tail, setTail] = React.useState(null)
1322
- const lock = React.useRef(false) // 防双发:state 的 busy 要等重渲染才生效,连点两下会漏
1323
- React.useEffect(() => {
1324
- if (model === null) setModel(String(cfg.backgroundModelId ?? cfg.modelId ?? ''))
1325
- if (wan === null) {
1326
- const t = Number(cfg.backgroundTokenBudget ?? cfg.tokenBudget) || 0
1327
- setWan(t > 0 ? String(Math.round(t / 10000)) : '')
1328
- }
1329
- }, [cfg])
1330
- /* 日志尾巴:后台那轮「在不在动」只能靠这本账 —— 它不进对话、也不进活动流水 */
1331
- const tailRev = React.useRef('')
1332
- React.useEffect(() => {
1333
- let dead = false
1334
- const pull = async () => {
1335
- try {
1336
- const r = await jget('/pet/log?n=10' + (tailRev.current ? '&rev=' + encodeURIComponent(tailRev.current) : ''))
1337
- if (!dead && r && r.ok !== false) {
1338
- if (r.unchanged) return // ⭐ 没新增行就不动(省一次解析+渲染)
1339
- if (r.rev) tailRev.current = String(r.rev)
1340
- setTail(Array.isArray(r.entries) ? r.entries : [])
1341
- }
1342
- } catch { /* 下次轮询会补 */ }
1343
- }
1344
- pull()
1345
- const t = setInterval(pull, 3000)
1346
- return () => { dead = true; clearInterval(t) }
1347
- }, [])
1348
- const bgBusy = Boolean(st && st.bgBusy)
1349
- const run = async () => {
1350
- if (lock.current || busy || bgBusy) return // 绝不双发:上一轮还活着就不再叠一轮
1351
- lock.current = true
1352
- setBusy(true)
1353
- try {
1354
- const body = { mode: 'background', tokenBudget: Math.max(0, Math.round((parseFloat(wan) || 0) * 10000)) }
1355
- if (model) body.model = model
1356
- const r = await jpost('/pet/start', body)
1357
- if (r && r.ok === false) {
1358
- toast('后台没跑起来:' + (r.error || '未知原因') + (r.hint ? ' —— ' + r.hint : ''), 'warn')
1359
- return
1360
- }
1361
- if (r && r.started) {
1362
- toast('后台那轮跑起来了 —— 几分钟,做完日志里会有回执', 'good')
1363
- if (onDone) onDone()
1364
- } else {
1365
- toast('后台没跑起来:' + ((r && r.error) || '可能上一轮还没结束'), 'warn')
1366
- }
1367
- } catch (e) { toast('后台没跑起来:' + String(e?.message ?? e), 'warn') }
1368
- finally { lock.current = false; setBusy(false) }
1369
- }
1370
- const cur = model === null ? '' : String(model)
1371
- const extra = cur && !rows.some((m) => m.id === cur) ? [{ id: cur, label: cur + '(不在当前清单里)' }] : []
1372
- return h('div', { 'data-bd-pet-card': '', 'data-wide': '' },
1373
- h('div', { 'data-bd-pet-cardhead': '' }, h('b', {}, '后台刷一轮'), h('span', {}, 'headless 独立进程')),
1374
- h('div', { 'data-bd-pet-pad': '' },
1375
- bgBusy ? h('span', { 'data-bd-pet-badge': '', 'data-warn': '' }, '后台正跑着呢 —— 等它做完再开下一轮') : null,
1376
- h('div', { 'data-bd-row': '' },
1377
- h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '模型'),
1378
- rows.length
1379
- ? h('select', {
1380
- 'data-bd-input': '', 'data-bd-num': '', style: { width: '190px' },
1381
- value: cur, onChange: (e) => setModel(e.target.value),
1382
- },
1383
- h('option', { value: '' }, '默认(跟着当前对话)'),
1384
- extra.length ? h('option', { key: 'extra', value: extra[0].id }, extra[0].label) : null,
1385
- rows.map((m) => h('option', { key: m.id, value: m.id }, m.label || m.id)))
1386
- : h('input', {
1387
- 'data-bd-input': '', style: { width: '190px' }, placeholder: 'provider/model(留空 = 默认)',
1388
- value: cur, onChange: (e) => setModel(e.target.value),
1389
- })),
1390
- h('div', { 'data-bd-row': '' },
1391
- h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, 'token 预算(万 · 0 = 不限)'),
1392
- h('input', {
1393
- 'data-bd-input': '', 'data-bd-num': '', style: { width: '88px' }, type: 'number', min: 0, step: 1,
1394
- placeholder: '不限', value: wan === null ? '' : wan,
1395
- onChange: (e) => setWan(e.target.value),
1396
- })),
1397
- h('div', { 'data-bd-note': '' }, '1 万 = 10000 token。后台预算是任务目标;实际运行时长在「设置 → 后台刷」中调整。'),
1398
- h('div', { 'data-bd-row': '' },
1399
- h('button', {
1400
- 'data-bd-btn': '', 'data-primary': '', disabled: busy || bgBusy, onClick: run,
1401
- }, busy ? '开跑…' : '开跑'),
1402
- busy ? h('i', { 'data-bd-pet-spin': '' }) : null)),
1403
- tail && tail.length
1404
- ? h('div', {
1405
- 'data-bd-pet-log': '',
1406
- style: { maxHeight: '122px', borderTop: '1px solid #d8e6ee' },
1407
- },
1408
- tail.slice(-4).map((e, i) => h('div', { 'data-bd-pet-logline': '', 'data-t': petEntryTone(e), key: i },
1409
- h('time', {}, petFmtTime(e && e.at)), h('span', {}, petEntrySummary(e)))))
1410
- : null)
1411
- }
1412
-
1413
- /* 📜 运行日志:GET /pet/log,3 秒自动刷新;type 着色(start 绿 / stop 灰 / error 红 / note 蓝)。
1414
- * 自动滚到底,但使用者往上翻时暂停自动滚(离底 <28px 才重新粘住),不然永远翻不上去。
1415
- * 顶部「停止鲸鱼摸鱼」复用现有 POST /feiyu {action:'stop'}(跟设置页「停一下」同一个端点)。 */
1416
- function PetLogPanel({ toast }) {
1417
- const [entries, setEntries] = React.useState(null)
1418
- const [busy, setBusy] = React.useState(false)
1419
- const [err, setErr] = React.useState('')
1420
- const listRef = React.useRef(null)
1421
- const stick = React.useRef(true)
1422
- const lock = React.useRef(false)
1423
- const revRef = React.useRef('')
1424
- React.useEffect(() => {
1425
- let dead = false
1426
- const pull = async () => {
1427
- try {
1428
- const r = await jget('/pet/log?n=200' + (revRef.current ? '&rev=' + encodeURIComponent(revRef.current) : ''))
1429
- if (dead) return
1430
- if (!r || r.ok === false) { setErr((r && r.error) || '读不到'); return }
1431
- if (r.unchanged) return // ⭐ 日志没新增行:不重解析、不重渲染
1432
- if (r.rev) revRef.current = String(r.rev)
1433
- setEntries(Array.isArray(r.entries) ? r.entries : [])
1434
- setErr('')
1435
- } catch (e) { if (!dead) setErr(String(e?.message ?? e)) }
1436
- }
1437
- pull()
1438
- const t = setInterval(pull, 3000)
1439
- return () => { dead = true; clearInterval(t) }
1440
- }, [])
1441
- React.useEffect(() => {
1442
- const el = listRef.current
1443
- if (el && stick.current) el.scrollTop = el.scrollHeight
1444
- }, [entries])
1445
- const onScroll = () => {
1446
- const el = listRef.current
1447
- if (!el) return
1448
- stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 28
1449
- }
1450
- const onStop = async () => {
1451
- if (lock.current) return
1452
- lock.current = true
1453
- setBusy(true)
1454
- try {
1455
- const r = await jpost('/feiyu', { action: 'stop' })
1456
- if (r && r.ok === false) toast('没停成:' + (r.error || r.hint || '未知原因'), 'warn')
1457
- else toast(r && r.rounds != null ? `上岸啦~这趟一共游了 ${r.rounds} 轮。` : '它本来就没在刷。', 'good')
1458
- } catch (e) { toast('没停成:' + String(e?.message ?? e), 'warn') }
1459
- finally { lock.current = false; setBusy(false) }
1460
- }
1461
- return h('div', { 'data-bd-pet-card': '', 'data-wide': '' },
1462
- h('div', { 'data-bd-pet-cardhead': '' },
1463
- h('b', {}, '运行日志'),
1464
- h('span', {}, '每 3 秒自动刷新'),
1465
- h('button', {
1466
- 'data-bd-btn': '', 'data-sm': '', style: { marginLeft: '8px' }, disabled: busy, onClick: onStop,
1467
- }, busy ? '停…' : '停止鲸鱼摸鱼')),
1468
- err ? h('div', { 'data-bd-pet-pad': '' }, h('div', { 'data-bd-note': '' }, '读不到日志:' + err)) : null,
1469
- h('div', { 'data-bd-pet-log': '', ref: listRef, onScroll },
1470
- !entries
1471
- ? h('div', { 'data-bd-pet-logline': '' }, h('span', {}, '正在读…'))
1472
- : entries.length === 0
1473
- ? h('div', { 'data-bd-pet-logline': '' }, h('span', {}, '还没动静 —— 开一次会话或跑一轮后台,这儿就会一条条记下来。'))
1474
- : entries.map((e, i) => h('div', { 'data-bd-pet-logline': '', 'data-t': petEntryTone(e), key: i },
1475
- h('time', {}, petFmtTime(e && e.at)), h('span', {}, petEntrySummary(e))))))
1476
- }
1477
-
1478
1387
  /* 🐟 状态卡:字段全部来自 /status(feiyu 激活/轮次/今日挑选/本轮 token、bgBusy、loggedIn) */
1479
1388
  function PetStatusCard({ st, fails }) {
1480
1389
  const fy = (st && st.feiyu) || {}
@@ -1538,6 +1447,7 @@ window.__ModuleLoader__.load({
1538
1447
  gear: ['M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0'],
1539
1448
  pulse: ['M3 12h4l3-8 4 16 3-8h4'], hide: ['M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.4 5.3A10.2 10.2 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-3 3.7M6.2 6.2A22 22 0 0 0 2 12s4 7 10 7a10.3 10.3 0 0 0 5-1.3'],
1540
1449
  sliders: ['M4 7h16M4 17h16M8 4v6M16 14v6'], spark: ['M12 3l2.4 6.6L21 12l-6.6 2.4L12 21l-2.4-6.6L3 12l6.6-2.4z'],
1450
+ compass: ['M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0', 'M16 8l-3 5-5 3 3-5z'],
1541
1451
  film: ['M4 4h16v16H4zM8 4v16M16 4v16M4 8h4M4 16h4M16 8h4M16 16h4'],
1542
1452
  chip: ['M7 7h10v10H7zM9 3v4M15 3v4M9 17v4M15 17v4M3 9h4M3 15h4M17 9h4M17 15h4'],
1543
1453
  dots: ['M5 12h.01M12 12h.01M19 12h.01'], key: ['M14 7a5 5 0 1 1-10 0 5 5 0 0 1 10 0M13 10l8 8M17 14l-2 2M20 17l-2 2'],
@@ -1567,12 +1477,17 @@ window.__ModuleLoader__.load({
1567
1477
  })
1568
1478
  }
1569
1479
 
1570
- function PetCompanionSettings({ st, busy, patch }) {
1480
+ function PetCompanionSettings({ st, busy, patch, say }) {
1571
1481
  const cfg = st?.config || {}
1572
1482
  const initial = () => ({ chatterFrequency: cfg.chatterFrequency || 'normal', notificationLevel: cfg.notificationLevel || 'normal',
1573
1483
  focusMode: cfg.focusMode === true, quietHoursEnabled: cfg.quietHoursEnabled === true,
1574
1484
  quietHoursStart: cfg.quietHoursStart || '23:00', quietHoursEnd: cfg.quietHoursEnd || '08:00', petReducedMotion: cfg.petReducedMotion === true })
1575
1485
  const [draft, setDraft] = React.useState(initial)
1486
+ /* 📌 P3-39:挂载时的初始快照。保存只提交与快照**不同**的键 —— 没动过的键不再
1487
+ * 整包重发(以前每次都把七个键原样打回 /config,纯噪音写入);全没动就提示
1488
+ * 「没有改动」。 */
1489
+ const [baseline, setBaseline] = React.useState(initial)
1490
+ const tell = (text, tone) => { if (typeof say === 'function') say(String(text || ''), tone) }
1576
1491
  const update = (key, value) => setDraft(old => ({ ...old, [key]: value }))
1577
1492
  const toggle = (key, title, note) => ToggleRow({ label: title, note, on: draft[key], disabled: Boolean(busy), onChange: value => update(key, value) })
1578
1493
  return h('div', { 'data-bd-companion-settings': '' },
@@ -1591,7 +1506,204 @@ window.__ModuleLoader__.load({
1591
1506
  h('span', { 'data-bd-note': '' }, draft.quietHoursStart === draft.quietHoursEnd ? '起止时间相同,安静时段不生效。' : '按本地时间执行,支持跨午夜。')) : null,
1592
1507
  toggle('petReducedMotion', '减少桌宠动效', '停用浮动、泡泡和事件动画,并遵循系统的减少动态效果设置。'),
1593
1508
  h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, '当前:' + (st?.companion?.label || '自然陪伴')),
1594
- h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: () => patch(draft, '陪伴节奏') }, '保存陪伴节奏')))
1509
+ h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: async () => {
1510
+ /* P3-39:只提交与初始快照不同的键,没有改动就不发请求 */
1511
+ const changed = Object.fromEntries(Object.entries(draft).filter(([key, value]) => value !== baseline[key]))
1512
+ if (!Object.keys(changed).length) { tell('陪伴节奏:没有改动'); return }
1513
+ if (await patch(changed, '陪伴节奏')) setBaseline({ ...draft })
1514
+ } }, '保存陪伴节奏')))
1515
+ }
1516
+
1517
+ function PetBrowseSettings({ st, busy, patch, say }) {
1518
+ const cfg = st?.config || {}
1519
+ const initial = () => ({ browseTopics: Array.isArray(cfg.browseTopics) ? [...cfg.browseTopics] : [],
1520
+ keywords: (Array.isArray(cfg.keywords) ? cfg.keywords : []).join('\n'),
1521
+ browseAvoidKeywords: (Array.isArray(cfg.browseAvoidKeywords) ? cfg.browseAvoidKeywords : []).join('\n'),
1522
+ browseExplorePercent: String(cfg.browseExplorePercent ?? 25), browseMinMinutes: String(cfg.browseMinMinutes ?? 0),
1523
+ browseMaxMinutes: String(cfg.browseMaxMinutes ?? 0), browseRecentDays: String(cfg.browseRecentDays ?? 7), browseSort: cfg.browseSort || 'click',
1524
+ browseMinViews: String(cfg.browseMinViews ?? 50000) })
1525
+ const [draft, setDraft] = React.useState(initial)
1526
+ const [baseline, setBaseline] = React.useState(initial)
1527
+ const update = (key, value) => setDraft(old => ({ ...old, [key]: value }))
1528
+ const topics = ['科技', '知识', '生活', '美食', '游戏', '动画', '音乐', '影视', '动物', '运动', '旅行']
1529
+ const field = (label, control) => h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, label), control)
1530
+ const select = (key, label, options) => field(label, h('select', { 'data-bd-input': '', 'aria-label': label, value: draft[key], disabled: Boolean(busy), onChange: e => update(key, e.target.value) },
1531
+ ...options.map(([value, text]) => h('option', { key: value, value }, text))))
1532
+ const minutes = (key, label) => field(label, h('input', { 'data-bd-input': '', 'aria-label': label, type: 'number', min: 0, max: 1440, step: 1,
1533
+ value: draft[key], disabled: Boolean(busy), onChange: e => update(key, e.target.value), style: { width: '100%', boxSizing: 'border-box' } }))
1534
+ const save = async () => {
1535
+ const integer = (key, max, label) => {
1536
+ const value = Number(draft[key])
1537
+ if (String(draft[key]).trim() === '' || !Number.isSafeInteger(value) || value < 0 || value > max) throw new Error(label + '需要填写 0 至 ' + max + ' 之间的整数')
1538
+ return value
1539
+ }
1540
+ try {
1541
+ const value = { ...draft, keywords: [...new Set(draft.keywords.split('\n').map(word => word.trim()).filter(Boolean))],
1542
+ browseAvoidKeywords: [...new Set(draft.browseAvoidKeywords.split('\n').map(word => word.trim()).filter(Boolean))],
1543
+ browseExplorePercent: integer('browseExplorePercent', 100, '探索比例'), browseRecentDays: integer('browseRecentDays', 365, '发布时间'),
1544
+ browseMinMinutes: integer('browseMinMinutes', 1440, '最短时长'), browseMaxMinutes: integer('browseMaxMinutes', 1440, '最长时长'),
1545
+ browseMinViews: integer('browseMinViews', 2_000_000_000, '最低播放量') }
1546
+ if (value.browseMaxMinutes > 0 && value.browseMinMinutes > value.browseMaxMinutes) throw new Error('最短时长不能大于最长时长;最长填 0 表示不限')
1547
+ if (value.browseAvoidKeywords.length > 30 || value.browseAvoidKeywords.some(word => word.length > 80)) throw new Error('避开关键词最多 30 个,每个不超过 80 字')
1548
+ const comparable = { ...draft, keywords: value.keywords.join('\n'), browseAvoidKeywords: value.browseAvoidKeywords.join('\n') }
1549
+ const changed = Object.fromEntries(Object.entries(value).filter(([key]) => JSON.stringify(comparable[key]) !== JSON.stringify(baseline[key])))
1550
+ if (!Object.keys(changed).length) { say('刷视频偏好:没有改动'); return }
1551
+ if (await patch(changed, '刷视频偏好')) setBaseline(comparable)
1552
+ } catch (error) { say(String(error?.message || error), 'warn') }
1553
+ }
1554
+ return h('section', { 'data-bd-sec': '', 'data-bd-span': '2', 'data-bd-browse-settings': '' },
1555
+ h('div', { 'data-bd-h': '' }, h(PetIcon, { name: 'compass' }), h('b', {}, '刷视频偏好')),
1556
+ h('div', { 'data-bd-note': '' }, '默认优先热门,至少 5 万播放;也可自定热度门槛。类别、关键词、日期和时长继续生效。保存后用于下一次启动。'),
1557
+ h('div', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '感兴趣的类别'),
1558
+ h('div', { 'data-bd-segments': '', role: 'group', 'aria-label': '感兴趣的类别', style: { flexWrap: 'wrap' } },
1559
+ ...topics.map(topic => h('button', { key: topic, 'data-bd-btn': '', 'data-bd-browse-topic': '', 'data-sm': '', type: 'button', disabled: Boolean(busy), 'aria-pressed': draft.browseTopics.includes(topic),
1560
+ onClick: () => update('browseTopics', draft.browseTopics.includes(topic) ? draft.browseTopics.filter(value => value !== topic) : [...draft.browseTopics, topic]) }, topic))),
1561
+ h('div', { 'data-bd-note': '' }, '可多选,留空随兴趣。')),
1562
+ h('div', { 'data-bd-browse-common': '' },
1563
+ field('喜欢的关键词', h('textarea', { 'data-bd-input': '', 'aria-label': '长期偏好关键词', rows: 2, placeholder: '一行一个,例如 DeepSeek、手工、做饭',
1564
+ value: draft.keywords, disabled: Boolean(busy), onChange: e => update('keywords', e.target.value) })),
1565
+ select('browseRecentDays', '视频发布时间', [['0', '不限,旧片也看'], ['1', '最近 1 天'], ['7', '最近 7 天'], ['30', '最近 30 天'], ['90', '最近 90 天'], ['365', '最近一年'],
1566
+ ...(!['0', '1', '7', '30', '90', '365'].includes(draft.browseRecentDays) ? [[draft.browseRecentDays, '最近 ' + draft.browseRecentDays + ' 天']] : [])])),
1567
+ field('最低播放量', h('input', { 'data-bd-input': '', 'aria-label': '最低播放量', type: 'number', min: 0, max: 2_000_000_000, step: 1000,
1568
+ value: draft.browseMinViews, disabled: Boolean(busy), onChange: e => update('browseMinViews', e.target.value) })),
1569
+ h('div', { 'data-bd-row': '', 'aria-label': '播放量快捷选择' }, ...[['0', '不限'], ['10000', '1 万'], ['50000', '5 万'], ['100000', '10 万']].map(([value, text]) => h('button', {
1570
+ key: value, type: 'button', 'data-bd-btn': '', 'data-sm': '', 'aria-pressed': draft.browseMinViews === value, disabled: Boolean(busy), onClick: () => update('browseMinViews', value) }, text))),
1571
+ h('div', { 'data-bd-note': '', 'data-bd-view-threshold': '' }, Number(draft.browseMinViews) > 0 ? '播放门槛草稿:至少 ' + Number(draft.browseMinViews).toLocaleString() + ' 次;保存后用于过滤候选视频。' : '播放门槛草稿:不限播放量。'),
1572
+ h('details', { 'data-bd-advanced': '', 'data-bd-browse-refine': '' },
1573
+ h('summary', {}, '精细选片', h('span', { 'data-bd-note': '' }, ' · '
1574
+ + (Number(draft.browseMinMinutes) || Number(draft.browseMaxMinutes) ? (Number(draft.browseMinMinutes) || 0) + '–' + (Number(draft.browseMaxMinutes) || '不限') + ' 分钟' : '时长不限')
1575
+ + ' · 避开 ' + draft.browseAvoidKeywords.split('\n').filter(word => word.trim()).length + ' 个词'
1576
+ + ' · ' + ({ mixed: '探索 ' + draft.browseExplorePercent + '%', pubdate: '最新优先', click: '播放量优先', totalrank: '平台综合' }[draft.browseSort])
1577
+ + ' · ' + (Number(draft.browseMinViews) > 0 ? '至少 ' + Number(draft.browseMinViews).toLocaleString() + ' 播放' : '播放不限'))),
1578
+ h('div', {},
1579
+ select('browseSort', '选片顺序', [['mixed', '兴趣与探索兼顾'], ['pubdate', '最新优先'], ['click', '播放量优先'], ['totalrank', '平台综合顺序']]),
1580
+ h('div', { 'data-bd-row': '', style: { alignItems: 'stretch' } }, minutes('browseMinMinutes', '最短时长(分钟)'), minutes('browseMaxMinutes', '最长时长(分钟)')),
1581
+ h('div', { 'data-bd-note': '' }, '0 表示不限。日期、时长缺失时先读取详情核对,避免误跳过。'),
1582
+ field('探索陌生内容的比例', h('div', { 'data-bd-row': '' },
1583
+ h('input', { 'data-bd-input': '', 'aria-label': '探索陌生内容的比例', type: 'range', min: 0, max: 100, step: 1, value: draft.browseExplorePercent,
1584
+ disabled: Boolean(busy), onChange: e => update('browseExplorePercent', e.target.value), style: { flex: '1 1 180px', minWidth: 0 } }),
1585
+ h('output', { 'data-bd-badge': '' }, draft.browseExplorePercent + '%'))),
1586
+ h('div', { 'data-bd-note': '' }, '在「兴趣与探索兼顾」时生效。低一些更贴口味,高一些更容易发现新东西;不会要求凑满数量。'),
1587
+ field('避开关键词', h('textarea', { 'data-bd-input': '', 'aria-label': '避开关键词', rows: 2, maxLength: 2400, placeholder: '一行一个,例如剧透、营销号或 UP 名称',
1588
+ value: draft.browseAvoidKeywords, disabled: Boolean(busy), onChange: e => update('browseAvoidKeywords', e.target.value) })),
1589
+ h('div', { 'data-bd-note': '' }, '最多 30 个避开关键词。类别和喜欢词为软偏好,日期、时长与避开词用于筛选。'))),
1590
+ h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, '运行时间在「运行方式」中安排。'),
1591
+ h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy), onClick: save }, '保存刷视频偏好')))
1592
+ }
1593
+
1594
+ function PetCuriosityJournal({ st, busy, onExplore }) {
1595
+ const [expanded, setExpanded] = React.useState(false)
1596
+ const [pane, setPane] = React.useState('explore')
1597
+ const [snapshot, setSnapshot] = React.useState(null)
1598
+ const [question, setQuestion] = React.useState('')
1599
+ const [error, setError] = React.useState('')
1600
+ const [working, setWorking] = React.useState('')
1601
+ const [refresh, setRefresh] = React.useState(0)
1602
+ const [findIndex, setFindIndex] = React.useState(0)
1603
+ const alive = React.useRef(true), visible = React.useRef(false), epoch = React.useRef(0), lock = React.useRef(false)
1604
+ visible.current = expanded
1605
+ React.useEffect(() => { alive.current = true; return () => { alive.current = false; epoch.current += 1 } }, [])
1606
+ const accept = result => {
1607
+ if (!result || result.ok === false || !Array.isArray(result.items) || !Array.isArray(result.finds)) throw new Error(result?.error || '手帐没有返回可读记录')
1608
+ return result
1609
+ }
1610
+ React.useEffect(() => {
1611
+ const generation = ++epoch.current
1612
+ if (!expanded || lock.current) return
1613
+ let dead = false
1614
+ lock.current = true; setWorking('read')
1615
+ ;(async () => {
1616
+ try {
1617
+ const result = accept(await jget('/curiosity?state=all'))
1618
+ if (!dead && alive.current && visible.current && generation === epoch.current) { setSnapshot(result); setError('') }
1619
+ } catch (failure) {
1620
+ if (!dead && alive.current && visible.current && generation === epoch.current) setError(String(failure?.message || failure))
1621
+ } finally {
1622
+ lock.current = false
1623
+ if (alive.current) {
1624
+ setWorking('')
1625
+ if (visible.current && generation !== epoch.current) setRefresh(value => value + 1)
1626
+ }
1627
+ }
1628
+ })()
1629
+ return () => { dead = true; epoch.current += 1 }
1630
+ }, [expanded, refresh])
1631
+ const mutate = async (action, item) => {
1632
+ if (lock.current || busy || !visible.current) return
1633
+ const text = question.trim()
1634
+ if (action === 'open' && !text) { setError('先写一个想弄清的问题。'); return }
1635
+ if (action === 'forget' && typeof window.confirm === 'function' && !window.confirm('从手帐中忘记这条线索及其拾光笔记?B站内容和浏览判重记录保持不变。\n\n' + item.question)) return
1636
+ lock.current = true; setWorking(action === 'open' ? 'open' : item.id)
1637
+ const generation = epoch.current
1638
+ try {
1639
+ const body = action === 'open' ? { action, question: text, query: text } : { action, id: item.id, version: item.version }
1640
+ const result = accept(await jpost('/curiosity', body))
1641
+ if (alive.current && visible.current && generation === epoch.current) {
1642
+ setSnapshot(result); setError(''); if (action === 'open') setQuestion(current => current.trim() === text ? '' : current)
1643
+ }
1644
+ } catch (failure) {
1645
+ if (alive.current && visible.current && generation === epoch.current) {
1646
+ const message = String(failure?.message || failure)
1647
+ setError(['CURIOSITY_STALE', 'CURIOSITY_CHANGED'].includes(failure?.code) || /CURIOSITY_(?:STALE|CHANGED)|记录已变化|版本/.test(message) ? '这条线索已有更新,请刷新后核对再操作。' : message)
1648
+ }
1649
+ } finally {
1650
+ lock.current = false
1651
+ if (alive.current) {
1652
+ setWorking('')
1653
+ if (visible.current && generation !== epoch.current) setRefresh(value => value + 1)
1654
+ }
1655
+ }
1656
+ }
1657
+ const link = value => {
1658
+ try { const url = new URL(String(value)); return url.protocol === 'https:' && ['www.bilibili.com', 'bilibili.com'].includes(url.hostname) && /^\/video\/BV[0-9A-Za-z]{10}/.test(url.pathname) ? url.href : '' } catch { return '' }
1659
+ }
1660
+ const items = Array.isArray(snapshot?.items) ? snapshot.items : [], finds = Array.isArray(snapshot?.finds) ? snapshot.finds : []
1661
+ const counts = snapshot?.counts || {}, active = Boolean(st.feiyu?.active || st.bgBusy)
1662
+ const disabled = Boolean(busy || working), selectedFind = finds.length ? finds[Math.min(findIndex, finds.length - 1)] : null
1663
+ const states = { active: '正在惦记', paused: '先放一放', resolved: '已收起' }
1664
+ return h('details', { 'data-bd-curiosity-journal': '', open: expanded, onToggle: event => setExpanded(event.currentTarget.open) },
1665
+ h('summary', {}, h('span', { 'data-bd-journal-title': '' }, '好奇心手帐'),
1666
+ h('span', { 'data-bd-note': '' }, snapshot ? ` · ${Number(counts.active) || 0} 条在意的线索 · ${finds.length} 段拾光` : '留一个问题,带着它去逛逛')),
1667
+ expanded ? h('div', { 'data-bd-journal-content': '' },
1668
+ h('div', { 'data-bd-row': '' },
1669
+ h('div', { 'data-bd-segments': '', role: 'group', 'aria-label': '手帐内容' },
1670
+ ...[['explore', '一起探索'], ['finds', '拾光回看']].map(([key, label]) => h('button', { key, type: 'button', 'data-bd-journal-pane': key, 'aria-pressed': pane === key, onClick: () => setPane(key) }, label))),
1671
+ h('span', { 'data-bd-grow': '' }),
1672
+ h('button', { 'data-bd-btn': '', 'data-sm': '', disabled, onClick: () => setRefresh(value => value + 1) }, '刷新手帐')),
1673
+ error ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'alert' }, error) : null,
1674
+ working === 'read' && !snapshot ? h('div', { 'data-bd-note': '', role: 'status' }, '正在翻开手帐…') : null,
1675
+ pane === 'explore' ? h('div', { 'data-bd-journal-explore': '' },
1676
+ h('form', { 'data-bd-journal-form': '', onSubmit: event => { event.preventDefault(); mutate('open') } },
1677
+ h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, '想和她一起弄清什么?'),
1678
+ h('input', { 'data-bd-input': '', 'aria-label': '留一个好奇的问题', maxLength: 80, value: question, placeholder: '例如:老电影里的雨声是怎么做出来的', disabled: Boolean(busy), onChange: event => setQuestion(event.target.value) })),
1679
+ h('button', { 'data-bd-btn': '', type: 'submit', disabled: disabled || !question.trim() }, working === 'open' ? '正在记下…' : '留个问题')),
1680
+ h('div', { 'data-bd-note': '' }, '问题先留在本地。点「带着它去逛逛」会新开会话,沿用运行参数和发送权限。管理手帐只影响之后选题;当前探索请在运行控制中暂停。'),
1681
+ active ? h('div', { 'data-bd-note': '' }, '当前任务运行中,结束或暂停后再开启新的探索。') : null,
1682
+ !items.length && snapshot ? h('div', { 'data-bd-journal-empty': '' }, '手帐还是空的。有想追问的事,先留下来。') : null,
1683
+ ...items.map(item => h('article', { key: item.id, 'data-bd-curiosity-id': item.id },
1684
+ h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-badge': '', 'data-live': item.status === 'active' ? '' : undefined }, states[item.status] || item.status),
1685
+ h('span', { 'data-bd-note': '' }, item.origin === 'fish' ? '她从看过的内容里想到的' : '你留下的问题'),
1686
+ h('span', { 'data-bd-grow': '' }),
1687
+ h('details', { 'data-bd-journal-manage': '' }, h('summary', {}, '管理线索'),
1688
+ h('div', { 'data-bd-row': '' }, ...[[item.status === 'active' ? 'pause' : 'resume', item.status === 'active' ? '先放一放' : '接着惦记'],
1689
+ ...(item.status !== 'resolved' ? [['resolve', '这次先到这']] : []), ['forget', '忘记这条']].map(([action, label]) => h('button', { key: action, 'data-bd-btn': '', 'data-sm': '', disabled, onClick: () => mutate(action, item) }, label))))),
1690
+ h('h3', {}, String(item.question || '')),
1691
+ /^BV[0-9A-Za-z]{10}$/.test(String(item.bvid || '')) ? h('a', { href: 'https://www.bilibili.com/video/' + item.bvid, target: '_blank', rel: 'noopener noreferrer', 'data-bd-journal-source': '' }, '最初看到的那条视频') : null,
1692
+ item.notes?.length ? h('p', { 'data-bd-note': '' }, '最近拾光:' + String(item.notes.at(-1)?.text || '')) : null,
1693
+ h('div', { 'data-bd-row': '' },
1694
+ h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `${Array.isArray(item.notes) ? item.notes.length : 0} 段有来源的笔记`),
1695
+ h('button', { 'data-bd-btn': '', 'data-primary': '', 'data-bd-curiosity-explore': item.id, disabled: disabled || active || item.status !== 'active' || (st.toolScope?.checked && !st.toolScope.supported),
1696
+ onClick: () => onExplore(item.id) }, '带着它去逛逛')))))
1697
+ : h('div', { 'data-bd-journal-finds': '' },
1698
+ h('div', { 'data-bd-note': '' }, '这些是她读后写下的理解,带着视频出处;时间点供回看,不代表逐句核验。'),
1699
+ selectedFind ? h('article', { 'data-bd-find-id': selectedFind.id },
1700
+ h('span', { 'data-bd-journal-eyebrow': '' }, selectedFind.question || '路上捡到的一点光'),
1701
+ h('blockquote', {}, String(selectedFind.text || '')),
1702
+ link(selectedFind.url) ? h('a', { href: link(selectedFind.url), target: '_blank', rel: 'noopener noreferrer' }, '回到视频' + (Number(selectedFind.seconds) > 0 ? ' · ' + Math.floor(selectedFind.seconds / 60) + ':' + String(Math.floor(selectedFind.seconds % 60)).padStart(2, '0') : '')) : h('span', { 'data-bd-note': '' }, '暂无可回看的视频链接'),
1703
+ h('div', { 'data-bd-row': '' }, h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `${Math.min(findIndex, finds.length - 1) + 1} / ${finds.length} · 只保存在本地`),
1704
+ h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: finds.length < 2, onClick: () => setFindIndex(value => (value + 1) % finds.length) }, '再翻一张')))
1705
+ : h('div', { 'data-bd-journal-empty': '' }, '还没有拾光。她读到真实内容、留下一点发现后,会把出处一起夹在这里。')))
1706
+ : null)
1595
1707
  }
1596
1708
 
1597
1709
  /* ⭐ 设置大弹窗(2026-10-26 桌宠化改造):从桌宠 ⚙️ 进来的居中弹窗。
@@ -1669,6 +1781,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1669
1781
  const [learnError, setLearnError] = React.useState('')
1670
1782
  const [learnBusy, setLearnBusy] = React.useState({})
1671
1783
  const [learnRefresh, setLearnRefresh] = React.useState(0)
1784
+ const [learningWorkspace, setLearningWorkspace] = React.useState('learned')
1785
+ const learningWorkspaceRef = React.useRef(learningWorkspace)
1786
+ learningWorkspaceRef.current = learningWorkspace
1672
1787
  const [mirrorNote, setMirrorNote] = React.useState('')
1673
1788
  const [soulBusy, setSoulBusy] = React.useState('')
1674
1789
  const [memoryQuery, setMemoryQuery] = React.useState('')
@@ -1713,7 +1828,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1713
1828
  }, [])
1714
1829
 
1715
1830
  const learnView = {
1716
- state: learnState,
1831
+ state: learningWorkspace === 'learned' ? 'approved' : learnState,
1717
1832
  kind: learnKind,
1718
1833
  filter: learnFilter,
1719
1834
  limit: 20,
@@ -1722,12 +1837,13 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1722
1837
  learnViewRef.current = learnView
1723
1838
 
1724
1839
  React.useEffect(() => {
1725
- if (view !== 'learning') return
1840
+ if (view !== 'learning' || learningWorkspace === 'memory') return
1726
1841
  let dead = false
1842
+ let missing = false
1727
1843
  let timer = null
1728
1844
  const queryView = { ...learnView }
1729
1845
  const request = async () => {
1730
- if (dead || !alive.current || learnInFlight.current.size || learnPulling.current) return
1846
+ if (dead || missing || !alive.current || learnInFlight.current.size || learnPulling.current) return
1731
1847
  const seq = ++learnRequest.current
1732
1848
  learnPulling.current = true
1733
1849
  const query = '?state=' + encodeURIComponent(queryView.state)
@@ -1736,7 +1852,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1736
1852
  + '&limit=' + queryView.limit + '&offset=' + queryView.offset
1737
1853
  try {
1738
1854
  const r = await jget('/learn' + query)
1739
- if (!r || r.ok === false) throw new Error((r && r.error) || '读学习记录失败')
1855
+ if (!r || r.ok === false || !Array.isArray(r.items)) throw new Error((r && r.error) || '学习记录接口尚未加载,完整重启 DSH 后重试')
1740
1856
  const current = learnViewRef.current || {}
1741
1857
  const same = current.state === queryView.state && current.kind === queryView.kind
1742
1858
  && current.filter === queryView.filter && Number(current.offset) === Number(queryView.offset)
@@ -1745,9 +1861,14 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1745
1861
  setLearnError('')
1746
1862
  }
1747
1863
  } catch (e) {
1748
- if (!dead && alive.current && seq === learnRequest.current) setLearnError(String(e && e.message || e))
1864
+ if (!dead && alive.current && seq === learnRequest.current) {
1865
+ if (e?.status === 404) { missing = true; if (timer) clearInterval(timer) }
1866
+ setLearnError(e?.status === 404 ? '学习记录接口不存在,请完整重启 DSH 后手动重新读取。' : String(e && e.message || e))
1867
+ }
1749
1868
  } finally {
1750
1869
  learnPulling.current = false
1870
+ // A view switch can finish while its predecessor is still in flight. Resume the selected view promptly.
1871
+ if (alive.current && learningWorkspaceRef.current !== 'memory' && (dead || seq !== learnRequest.current)) setLearnRefresh(n => n + 1)
1751
1872
  }
1752
1873
  }
1753
1874
  request()
@@ -1757,10 +1878,10 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1757
1878
  learnRequest.current += 1
1758
1879
  if (timer) clearInterval(timer)
1759
1880
  }
1760
- }, [view, learnState, learnKind, learnFilter, learnOffset, learnRefresh])
1881
+ }, [view, learningWorkspace, learnState, learnKind, learnFilter, learnOffset, learnRefresh])
1761
1882
 
1762
1883
  React.useEffect(() => {
1763
- if (view !== 'learning') return
1884
+ if (view !== 'learning' || learningWorkspace !== 'memory') return
1764
1885
  let dead = false
1765
1886
  const seq = ++memoryRequest.current
1766
1887
  const timer = setTimeout(async () => {
@@ -1771,7 +1892,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1771
1892
  } catch (e) { if (!dead && alive.current && seq === memoryRequest.current) setMemoryError(String(e?.message || e)) }
1772
1893
  }, memoryQuery ? 220 : 0)
1773
1894
  return () => { dead = true; clearTimeout(timer); memoryRequest.current += 1 }
1774
- }, [view, memorySource, memoryQuery, memoryOffset, memoryRefresh, st?.rev])
1895
+ }, [view, learningWorkspace, memorySource, memoryQuery, memoryOffset, memoryRefresh, st?.rev])
1775
1896
 
1776
1897
  const icon = (name) => h(PetIcon, { name })
1777
1898
  const sectionHead = (_name, title, extra) => h('div', { 'data-bd-h': '' }, h('b', {}, title), extra || null)
@@ -1943,6 +2064,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
1943
2064
  const tasteTop = Array.isArray(tasteInfo.top) ? tasteInfo.top : []
1944
2065
  const tasteLines = Array.isArray(tasteInfo.lines) ? tasteInfo.lines : []
1945
2066
  const focusLearning = (state) => {
2067
+ setLearningWorkspace(state === 'approved' ? 'learned' : 'review')
1946
2068
  setLearnState(state); setLearnFilter(''); setLearnKind(''); setLearnOffset(0); setLearnQ(null)
1947
2069
  learnQueueRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
1948
2070
  }
@@ -2010,6 +2132,16 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2010
2132
  } }
2011
2133
  finally { memoryActionLock.current = false; if (alive.current) setMemoryWorking('') }
2012
2134
  }
2135
+ const learningUseNote = (row) => {
2136
+ const practice = row.practice && typeof row.practice === 'object' ? row.practice : {}
2137
+ const reported = Number.isSafeInteger(practice.reported) && practice.reported > 0 ? practice.reported : 0
2138
+ if (!row.when && !reported) return null
2139
+ return h('div', { 'data-bd-learning-application': '' },
2140
+ row.when ? h('div', { 'data-bd-note': '' }, '适用场景:' + String(row.when)) : null,
2141
+ reported ? h('div', { 'data-bd-note': '' }, '实践记录(角色自报):' + reported + ' 次 · '
2142
+ + ({ tried: '已经尝试', helpful: '自评有帮助', unhelpful: '自评需调整' }[practice.lastOutcome] || '已记录'),
2143
+ practice.lastNote ? h('div', {}, String(practice.lastNote)) : null) : null)
2144
+ }
2013
2145
  const memoryLabels = { all: '全部记忆', learning: '学习经验', soul: '心情记录', voice: '语气札记', taste: '口味样本', seen: '浏览历史', history: '版本与审计' }
2014
2146
  const memoryItems = Array.isArray(memoryData?.items) ? memoryData.items : []
2015
2147
  const memoryExplorerCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2', 'data-bd-memory-explorer': '' },
@@ -2031,6 +2163,8 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2031
2163
  row.label ? h('strong', { 'data-bd-label': '', 'data-bd-grow': '' }, row.label) : null,
2032
2164
  h('time', { 'data-bd-note': '' }, formatTime(row.at) || '时间未记录')),
2033
2165
  h('p', {}, row.text),
2166
+ learningUseNote(row),
2167
+ row.example ? h('blockquote', { 'data-bd-example': '' }, h('span', { 'data-bd-note': '' }, '参考例句(理解用法,不照搬)'), h('div', {}, String(row.example))) : null,
2034
2168
  row.from ? h('div', { 'data-bd-note': '' }, '来源:' + row.from) : null,
2035
2169
  row.why ? h('div', { 'data-bd-note': '' }, '决定理由:' + row.why) : null,
2036
2170
  row.state ? h('span', { 'data-bd-note': '' }, '当前状态:' + ({ pending: '待审', approved: '已批准', rejected: '已拒绝' }[row.state] || row.state)) : null,
@@ -2039,9 +2173,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2039
2173
  row.actions.includes('forget') ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(memoryWorking) || Boolean(busy), onClick: () => changeMemory(row, 'forget') }, memoryWorking === row.source + ':' + row.id ? '正在移除…' : '从当前记忆移除') : null)
2040
2174
  : h('span', { 'data-bd-note': '' }, '只读历史记录')))),
2041
2175
  h('div', { 'data-bd-row': '' },
2042
- h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, memoryData ? `匹配 ${Number(memoryData.matched) || 0} 条 · 当前 ${memoryItems.length ? memoryOffset + 1 : 0}–${memoryOffset + memoryItems.length};审计范围为最近 100 条` : '按需读取,离页停止'),
2176
+ h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, memoryData ? `匹配 ${Number(memoryData.matched ?? memoryData.total) || 0} 条 · 当前 ${memoryItems.length ? memoryOffset + 1 : 0}–${memoryOffset + memoryItems.length};审计范围为最近 100 条` : '按需读取,离页停止'),
2043
2177
  h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: memoryOffset <= 0 || Boolean(memoryWorking), onClick: () => { setMemoryOffset(Math.max(0, memoryOffset - 20)); setMemoryData(null) } }, '前 20 条'),
2044
- h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: !memoryData?.more || Boolean(memoryWorking), onClick: () => { setMemoryOffset(memoryOffset + 20); setMemoryData(null) } }, '后 20 条'),
2178
+ h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: !(memoryData?.hasMore ?? memoryData?.more) || Boolean(memoryWorking), onClick: () => { setMemoryOffset(Number.isSafeInteger(memoryData?.nextOffset) ? memoryData.nextOffset : memoryOffset + 20); setMemoryData(null) } }, '后 20 条'),
2045
2179
  h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(memoryWorking), onClick: () => setMemoryRefresh((n) => n + 1) }, '重新读取记忆')))
2046
2180
  const q = learnQ && sameLearnView(learnView) ? learnQ : null
2047
2181
  const items = q && Array.isArray(q.items) ? q.items : []
@@ -2068,10 +2202,11 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2068
2202
  one.decidedAt ? h('span', { 'data-bd-note': '' }, '决定于 ' + formatTime(one.decidedAt)) : null,
2069
2203
  learnBusy[one.id] ? h('span', { 'data-bd-note': '', role: 'status' }, '处理中…') : null),
2070
2204
  h('div', { 'data-bd-label': '' }, String(one.text || '')),
2205
+ learningUseNote(one),
2071
2206
  one.example ? h('blockquote', { 'data-bd-example': '' },
2072
- h('span', { 'data-bd-note': '' }, '参考例句(外部数据)'), h('div', {}, String(one.example))) : null,
2073
- one.from ? h('div', { 'data-bd-note': '', 'data-bd-source': '' },
2074
- '来源:' + String(one.from),
2207
+ h('span', { 'data-bd-note': '' }, '参考例句(理解用法,不照搬)'), h('div', {}, String(one.example))) : null,
2208
+ one.from || Array.isArray(one.sources) && one.sources.length ? h('div', { 'data-bd-note': '', 'data-bd-source': '' },
2209
+ '来源:' + String(one.from || one.sources.join(';')),
2075
2210
  ...links.map((bvid) => h('a', { key: bvid, href: 'https://www.bilibili.com/video/' + bvid, target: '_blank', rel: 'noopener noreferrer' }, '打开 ' + bvid))) : null,
2076
2211
  one.why ? h('div', { 'data-bd-note': '' }, '决定理由:' + String(one.why)) : null,
2077
2212
  h('div', { 'data-bd-note': '' }, `审计:记录于 ${formatTime(one.at) || '未知'} · 版本 ${Number(one.version) || 1}`),
@@ -2080,9 +2215,10 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2080
2215
  canReject ? h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: pending, onClick: () => decideLearn(one.id, 'reject') }, icon('dots'), one.state === 'approved' ? '改为拒绝' : '拒绝') : null,
2081
2216
  h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: pending, onClick: () => decideLearn(one.id, 'forget') }, icon('gear'), '移除记录')))
2082
2217
  }) : null
2083
- const learnCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2', 'data-bd-learning-queue': '', ref: learnQueueRef },
2084
- h('div', { 'data-bd-row': '' }, sectionHead('list', '学习队列'),
2218
+ const learnCard = () => h('section', { 'data-bd-sec': '', 'data-bd-span': '2', 'data-bd-learning-queue': '', 'data-bd-learned': learningWorkspace === 'learned' ? '' : undefined, ref: learnQueueRef },
2219
+ h('div', { 'data-bd-row': '' }, sectionHead('list', learningWorkspace === 'learned' ? '已学会 · 已批准经验' : '学习审核与历史'),
2085
2220
  h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `待审 ${Number(learnInfo.pending) || 0} · 已批准 ${Number(learnInfo.approved) || 0} · 已拒绝 ${Number(learnInfo.rejected) || 0}`)),
2221
+ learningWorkspace === 'review' ? reviewCard : null,
2086
2222
  /* 判重账本概览(2026-10-27 从旧设置页搬进来):「刷过不再刷、回过不再回」记了多少得有地方看 */
2087
2223
  (() => {
2088
2224
  const sn = st && st.seen
@@ -2091,32 +2227,35 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2091
2227
  `判重账本:已浏览 ${Number(sn.videos) || 0} 个视频(今日新增 ${Number(sn.videosToday) || 0} 个)· `
2092
2228
  + `已回复 ${Number(sn.replied) || 0} 条评论`)
2093
2229
  })(),
2094
- h('div', { 'data-bd-note': '' }, '已批准内容可注入后续提示词。记录保留例句、来源、决定理由和版本;移除当前记录后仍保留审计。'),
2230
+ h('div', { 'data-bd-note': '' }, '已批准经验按当前情境选入后续提示词。适用情境和实践记录帮助她下次用上;实践为角色自报,不代表效果已核实。移除当前记录后仍保留审计。'),
2095
2231
  h('div', { 'data-bd-row': '' },
2096
- ...[['pending', '待审'], ['approved', '已批准'], ['rejected', '已拒绝'], ['all', '全部']].map(([value, label]) => h('button', {
2232
+ ...(learningWorkspace === 'review' ? [['pending', '待审'], ['rejected', '已拒绝'], ['all', '全部']] : []).map(([value, label]) => h('button', {
2097
2233
  key: value, 'data-bd-btn': '', 'data-sm': '', 'data-primary': learnState === value ? '' : undefined,
2098
2234
  onClick: () => { setLearnState(value); setLearnOffset(0); setLearnQ(null) },
2099
2235
  }, label)),
2100
2236
  h('select', { 'data-bd-input': '', 'aria-label': '学习记录类别', style: { width: '130px', flex: '0 1 130px' }, value: learnKind, onChange: (e) => { setLearnKind(e.target.value); setLearnOffset(0); setLearnQ(null) } },
2101
2237
  h('option', { value: '' }, '全部类别'), ...kinds.map((kind) => h('option', { key: kind, value: kind }, labels[kind] || kind)))),
2102
2238
  h('input', {
2103
- 'data-bd-input': '', value: learnFilter, maxLength: 160, placeholder: '按正文、例句、来源或决定理由筛选',
2239
+ 'data-bd-input': '', value: learnFilter, maxLength: 160, placeholder: '按正文、适用场景、例句、来源或决定理由筛选',
2104
2240
  'aria-label': '筛选学习记录', onChange: (e) => { setLearnFilter(e.target.value); setLearnOffset(0); setLearnQ(null) },
2105
2241
  }),
2106
- learnError ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '学习记录读取失败:' + learnError) : null,
2107
- !q ? h('div', { 'data-bd-list-empty': '', role: 'status' }, '正在读取学习记录…')
2108
- : !items.length ? h('div', { 'data-bd-list-empty': '' }, learnFilter ? '无匹配的学习记录' : '该状态暂无学习记录')
2242
+ learnError ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'alert' }, '学习记录读取失败:' + learnError) : null,
2243
+ !q ? h('div', { 'data-bd-list-empty': '', role: 'status' }, learnError ? '读取失败,已保存的经验未被删除。请重新读取。' : '正在读取学习记录…')
2244
+ : !items.length ? h('div', { 'data-bd-list-empty': '' }, learnFilter ? '无匹配的学习记录,可清空关键词或更换类别。'
2245
+ : learningWorkspace === 'learned' ? '暂无已批准经验。待审内容通过审核后会出现在这里。' : '该状态暂无学习记录;已批准的内容可在“已学会”查看。')
2109
2246
  : h('div', { 'data-bd-learn-list': '', tabIndex: 0, 'aria-label': '学习记录列表' }, ...learnRows),
2110
- q ? h('div', { 'data-bd-row' : '' },
2111
- h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, `显示 ${items.length ? learnOffset + 1 : 0}–${learnOffset + items.length} / ${q.matched == null ? items.length : q.matched} 条`),
2247
+ h('div', { 'data-bd-row' : '' },
2248
+ h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, q ? `显示 ${items.length ? learnOffset + 1 : 0}–${learnOffset + items.length} / ${q.matched ?? q.total ?? items.length} 条` : '按需读取,切到记忆检索后停止刷新'),
2112
2249
  h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: learnOffset <= 0 || Boolean(busy), onClick: () => { setLearnOffset(Math.max(0, learnOffset - 20)); setLearnQ(null) } }, '上一页'),
2113
- h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: !q.more || Boolean(busy), onClick: () => { setLearnOffset(learnOffset + 20); setLearnQ(null) } }, '下一页')) : null
2250
+ h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: !(q?.hasMore ?? q?.more) || Boolean(busy), onClick: () => { setLearnOffset(Number.isSafeInteger(q?.nextOffset) ? q.nextOffset : learnOffset + 20); setLearnQ(null) } }, '下一页'),
2251
+ h('button', { 'data-bd-btn': '', 'data-sm': '', disabled: Boolean(busy), onClick: () => setLearnRefresh(n => n + 1) }, '重新读取学习'))
2114
2252
  )
2115
2253
 
2116
2254
  const reviewStatus = (st && st.learnReview) || {}
2255
+ const hasReviewBatch = Boolean(reviewStatus.id || reviewStatus.batchKey || reviewStatus.lastStartedAt || reviewStatus.lastFinishedAt
2256
+ || reviewStatus.running || reviewStatus.complete || reviewStatus.lastError || Number(reviewStatus.count) > 0 || Number(reviewStatus.approved) > 0 || Number(reviewStatus.rejected) > 0)
2117
2257
  const reviewModels = Array.isArray(st && st.models) ? st.models : []
2118
- const reviewCard = h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
2119
- sectionHead('pulse', '后台自动审核'),
2258
+ const reviewCard = h('div', { 'data-bd-review-controls': '' },
2120
2259
  h('div', { 'data-bd-row': '', 'data-bd-toggle': '' },
2121
2260
  h('div', { 'data-bd-grow': '' },
2122
2261
  h('div', { 'data-bd-label': '' }, '自动审核待审内容'),
@@ -2155,16 +2294,15 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2155
2294
  try { const r = check(await jpost('/learn/review', { action: 'stop' }), '停止模型审核失败'); if (r.learnReview) setSt((old) => ({ ...old, learnReview: r.learnReview })); safeSay('模型审核已请求停止。', 'good') }
2156
2295
  catch (e) { safeSay(String(e && e.message || e), 'warn') } finally { setBusy('') }
2157
2296
  } }, '停止审核') : null),
2158
- h('div', { 'data-bd-row': '' },
2297
+ hasReviewBatch ? h('div', { 'data-bd-row': '', 'data-bd-review-receipt': '' },
2159
2298
  h('span', { 'data-bd-badge': '', 'data-live': reviewStatus.running ? '' : undefined }, reviewStatus.running ? '审核中' : reviewStatus.complete ? '本批完成' : reviewStatus.lastError ? '本批未完成' : '未运行'),
2160
- h('span', { 'data-bd-badge': '' }, '全部待审 ' + Number(reviewStatus.pending ?? learnInfo.pending ?? 0)),
2161
2299
  h('span', { 'data-bd-badge': '' }, '本批批准 ' + Number(reviewStatus.approved || 0)),
2162
2300
  h('span', { 'data-bd-badge': '' }, '本批拒绝 ' + Number(reviewStatus.rejected || 0)),
2163
- reviewStatus.remaining != null ? h('span', { 'data-bd-badge': '' }, '本批剩余 ' + Number(reviewStatus.remaining)) : null),
2164
- reviewStatus.model ? h('div', { 'data-bd-note': '' }, (reviewStatus.running ? '正在使用:' : '最近使用:') + reviewStatus.model) : null,
2301
+ reviewStatus.remaining != null ? h('span', { 'data-bd-badge': '' }, '本批剩余 ' + Number(reviewStatus.remaining)) : null) : null,
2302
+ hasReviewBatch && reviewStatus.model ? h('div', { 'data-bd-note': '' }, (reviewStatus.running ? '正在使用:' : '最近使用:') + reviewStatus.model) : null,
2165
2303
  reviewStatus.lastError ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, '审核提示:' + reviewStatus.lastError) : null,
2166
2304
  !reviewStatus.running && Number(reviewStatus.nextRetryAt) > 0 ? h('div', { 'data-bd-note': '' }, '自动重试最早:' + formatTime(reviewStatus.nextRetryAt) + (reviewStatus.enabled ? '' : '(自动审核未开启)')) : null,
2167
- h('div', { 'data-bd-note': '' }, reviewStatus.lastFinishedAt ? '上次结束:' + formatTime(reviewStatus.lastFinishedAt) : '暂无审核记录。启用自动审核后,将在下次检查时处理待审队列。'))
2305
+ h('div', { 'data-bd-note': '' }, reviewStatus.lastFinishedAt ? '上次结束:' + formatTime(reviewStatus.lastFinishedAt) : '后台自动审核:开启后处理待审队列,也可手动批量审核。'))
2168
2306
 
2169
2307
  /* 🔄 更新通道(2026-10-30 使用者「加一个更新通道,方便及时更新」)
2170
2308
  * 只在这里查/升级;普通鱼工具拿不到升级能力(换版本要重启宿主,不能让会话模型自己决定)。
@@ -2183,7 +2321,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2183
2321
  setUpdateError(r.error || '')
2184
2322
  } catch (e) { if (updateAlive.current) setUpdateError(String(e && e.message || e)) }
2185
2323
  }
2186
- React.useEffect(() => { pullUpdate() }, [])
2324
+ /* ⭐ P3-36:updateCard 只在「账号与环境」页渲染(learning 页不挂这张卡),
2325
+ 预取也只在该页跑 —— learning 页挂载时白跑一次 /update 徒增一次接口往返。 */
2326
+ React.useEffect(() => { if (view !== 'learning') pullUpdate() }, [view])
2187
2327
  const updateCard = (() => {
2188
2328
  const u = updateState || {}
2189
2329
  const install = u.install || {}
@@ -2299,8 +2439,12 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2299
2439
  return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': view === 'learning' ? 'learning' : 'other' },
2300
2440
  h('div', { 'data-bd-pghead': '' }, h('div', {}, h('b', {}, view === 'learning' ? '学习与记忆' : '账号与环境'),
2301
2441
  h('p', {}, view === 'learning' ? '管理学习审核、角色记忆、口味统计和历史记录。' : '管理账号登录、运行依赖和本地数据。'))),
2442
+ view === 'learning' ? h('div', { 'data-bd-segments': '', role: 'group', 'aria-label': '学习工作区' },
2443
+ ...[['learned', '已学会'], ['review', '待审学习'], ['memory', '记忆检索']].map(([key, label]) => h('button', { key, type: 'button', 'data-bd-learning-workspace': key,
2444
+ 'aria-pressed': learningWorkspace === key, onClick: () => { setLearningWorkspace(key); setLearnQ(null); setLearnOffset(0); setLearnError('') } }, label,
2445
+ key !== 'memory' ? h('span', { 'data-bd-note': '' }, ' · ' + Number(learnInfo[key === 'learned' ? 'approved' : 'pending'] || 0)) : null))) : null,
2302
2446
  h('div', { 'data-bd-pet-grid': '', 'data-bd-other': '' },
2303
- ...(view === 'learning' ? [memoryOverviewCard, memoryExplorerCard, reviewCard, learnCard, soulCard] : [accountCard, updateCard, asrCard, mirrorCard, groundCard])))
2447
+ ...(view === 'learning' ? learningWorkspace !== 'memory' ? [learnCard()] : [memoryOverviewCard, memoryExplorerCard, soulCard] : [accountCard, updateCard, asrCard, mirrorCard, groundCard])))
2304
2448
  }
2305
2449
 
2306
2450
 
@@ -2619,14 +2763,14 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2619
2763
  h('button', { 'data-bd-btn': '', 'data-primary': '', type: 'submit', disabled }, working === 'collect' ? '正在收集…' : '加入表情库'))))
2620
2764
  }
2621
2765
 
2622
- function PetSettingsModal({ onClose, toast, ctx, status, initialTab = 'run' }) {
2766
+ function PetSettingsModal({ onClose, toast, ctx, status, initialTab = 'run', initialRunMode = 'session' }) {
2623
2767
  const [tab, setTab] = React.useState(initialTab)
2624
2768
  const activeGroup = ({ stickers: 'learning', ledger: 'logs', review: 'logs', other: 'basic' })[tab] || tab
2625
2769
  React.useEffect(() => {
2626
2770
  const body = modalRef.current?.querySelector('[data-bd-pet-body]')
2627
2771
  if (body) body.scrollTop = 0
2628
2772
  }, [tab])
2629
- const [runMode, setRunMode] = React.useState('session')
2773
+ const [runMode, setRunMode] = React.useState(initialRunMode === 'background' ? 'background' : 'session')
2630
2774
  const [logMode, setLogMode] = React.useState('events')
2631
2775
  const [runDrafts, setRunDrafts] = React.useState({})
2632
2776
  const runLock = React.useRef(false)
@@ -2640,7 +2784,6 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2640
2784
  const [msg, setMsg] = React.useState('')
2641
2785
  const [msgTone, setMsgTone] = React.useState('')
2642
2786
  /* 输入一律绑本地草稿:5 秒轮询会把正在打的字冲掉 */
2643
- const [kws, setKws] = React.useState(null)
2644
2787
  const [wsDir, setWsDir] = React.useState(null) // 跟 uiWorkspace 服务的名字区分开
2645
2788
  const [rl, setRl] = React.useState(null)
2646
2789
  const [perDay, setPerDay] = React.useState(null)
@@ -2692,6 +2835,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2692
2835
  单独 2.5 秒增量轮询,跟 5 秒的 /status 分开,不然那条流会一顿一顿的。 */
2693
2836
  const [acts, setActs] = React.useState([])
2694
2837
  const [actLive, setActLive] = React.useState(null)
2838
+ /* ⚠️ P3-37:/activity 404(宿主还没加载这版接口)不是抖动 —— 停轮询 + 出提示条,
2839
+ 不然每 2.5s 打一次必 404 的接口,网络面板和日志里全是噪音。 */
2840
+ const [actRouteMissing, setActRouteMissing] = React.useState(false)
2695
2841
  const pvRunning = Boolean(pv && pv.running)
2696
2842
  React.useEffect(() => {
2697
2843
  if (tab !== 'video' || pvRouteMissing.current) return
@@ -2732,6 +2878,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2732
2878
  React.useEffect(() => {
2733
2879
  if (tab !== 'logs') return
2734
2880
  let dead = false
2881
+ let timer = null
2735
2882
  let sinceUid = activityCursor.current
2736
2883
  const pull = async () => {
2737
2884
  if (dead || activityRequest.current) return
@@ -2760,12 +2907,20 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2760
2907
  return next.length > 200 ? next.slice(next.length - 200) : next
2761
2908
  })
2762
2909
  }
2763
- } catch { /* 抖一下就算了,下次接着拉 */ }
2910
+ } catch (e) {
2911
+ /* ⚠️ P3-37:404 不是抖动 —— 接口压根不存在(宿主较旧 / 没热加载),
2912
+ 停掉这条轮询并提示用户重启;超时等其他失败仍按「抖一下」处理。 */
2913
+ if (e?.status === 404) {
2914
+ if (!dead) setActRouteMissing(true)
2915
+ if (timer) { clearInterval(timer); timer = null }
2916
+ return
2917
+ }
2918
+ }
2764
2919
  finally { if (activityRequest.current === token) activityRequest.current = null }
2765
2920
  }
2766
2921
  pull()
2767
- const t = setInterval(pull, 2500)
2768
- return () => { dead = true; activityEpoch.current += 1; activityRequest.current = null; clearInterval(t) }
2922
+ timer = setInterval(pull, 2500)
2923
+ return () => { dead = true; activityEpoch.current += 1; activityRequest.current = null; if (timer) clearInterval(timer) }
2769
2924
  }, [tab])
2770
2925
 
2771
2926
  const onClearAct = async () => {
@@ -2823,7 +2978,6 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2823
2978
  }
2824
2979
  React.useEffect(() => {
2825
2980
  if (!cfg) return
2826
- if (kws === null) setKws((cfg.keywords || []).join('\n'))
2827
2981
  if (wsDir === null) setWsDir(cfg.workspace || '')
2828
2982
  if (rl === null) setRl(cfg.reportLevel || 'full')
2829
2983
  if (perDay === null) setPerDay(String(cfg.perDay ?? 5))
@@ -2852,6 +3006,8 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2852
3006
  setBusy(JSON.stringify(p))
2853
3007
  try {
2854
3008
  const r = await jpost('/config', p)
3009
+ if (!r || r.ok === false) throw new Error(r?.error || '设置保存失败')
3010
+ if ('persona' in p && typeof r.config?.persona !== 'string') throw new Error('未收到人格保存回执,请重新读取设置后重试。')
2855
3011
  if (r?.config) setSt((old) => ({ ...old, config: r.config }))
2856
3012
  const clamped = Array.isArray(r?.clamped) ? r.clamped : []
2857
3013
  const clampTxt = clamped.map((c) => `${c.key} 想要 ${c.want},最多只能 ${c.got}`).join(';')
@@ -2873,6 +3029,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
2873
3029
  } catch (e) { say(String(e?.message ?? e), 'warn'); return false }
2874
3030
  finally { setBusy('') }
2875
3031
  }
3032
+ /* 🔐 P2-15:patch 的单飞包装(见 useLatestPatch 注释)。时间/下拉这类没有 disabled
3033
+ * 挡板的控件连改两下时会并发 PATCH,走它串行化。 */
3034
+ const patchLatest = useLatestPatch(patch)
2876
3035
  const sw = (k) => Boolean(cfg && cfg[k])
2877
3036
 
2878
3037
  const onLoginStart = async () => {
@@ -3111,7 +3270,9 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3111
3270
  const saveModel = async (value) => {
3112
3271
  const previous = currentModel
3113
3272
  setVideoModelId(value)
3114
- if (!(await patch({ videoModelId: value }, '制作模型'))) setVideoModelId(previous)
3273
+ /* 🔐 P2-15:走单飞 —— 在飞期间再改,新值只记 pending(本地 select 已更新、
3274
+ * 不回弹),当前请求结束后补发;直接发送失败才回弹旧值。 */
3275
+ if (!(await patchLatest({ videoModelId: value }, '制作模型'))) setVideoModelId(previous)
3115
3276
  }
3116
3277
  const selectedSession = selected && (selected.sessionId || sessions.find((item) => item.productionId === selected.id)?.sessionId)
3117
3278
  return h('div', { 'data-bd-pet-grid': '', 'data-bd-video-studio': '' },
@@ -3204,12 +3365,13 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3204
3365
  const value = e.target.value
3205
3366
  if (!/^\d{2}:\d{2}$/.test(value)) return
3206
3367
  setPv((old) => ({ ...(old || {}), at: value }))
3207
- patch({ pvAt: value }, '自动做视频的时间')
3368
+ /* 🔐 P2-15:单飞防并发 PATCH 乱序回写(见 useLatestPatch) */
3369
+ patchLatest({ pvAt: value }, '自动做视频的时间')
3208
3370
  } }))),
3209
3371
  h('section', { 'data-bd-sec': '', 'data-bd-span': '2' },
3210
3372
  heading('film', '配音与本地环境'),
3211
3373
  h('label', { 'data-bd-row': '' }, h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '配音音色'),
3212
- h('select', { 'data-bd-input': '', style: { width: '230px' }, value: pv?.voice || 'zh-CN-XiaoyiNeural', onChange: (e) => patch({ pvVoice: e.target.value }, '配音音色') },
3374
+ h('select', { 'data-bd-input': '', style: { width: '230px' }, value: pv?.voice || 'zh-CN-XiaoyiNeural', onChange: (e) => patchLatest({ pvVoice: e.target.value }, '配音音色') },
3213
3375
  h('option', { value: 'zh-CN-XiaoyiNeural' }, '晓伊(默认 · 偏软)'), h('option', { value: 'zh-CN-XiaoxiaoNeural' }, '晓晓(更清亮)'), h('option', { value: 'zh-CN-XiaochenNeural' }, '晓辰(偏成熟)'))),
3214
3376
  ...[
3215
3377
  ['GPT-SoVITS 目录', 'gsvRepo', 'pvGsvRepo', 'D:\\...\\GPT-SoVITS'],
@@ -3313,26 +3475,26 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3313
3475
  }
3314
3476
  } catch (e) { say(String(e?.message ?? e), 'warn') }
3315
3477
  }
3316
- const startRun = async (mode) => {
3317
- if (runLock.current) return
3318
- if (mode === 'session' && st.toolScope?.checked && !st.toolScope.supported) { say('当前 DSH 不支持会话工具隔离,无法启动会话运行。', 'warn'); return }
3478
+ const startRun = async (mode, { curiosityId = '' } = {}) => {
3479
+ if (runLock.current) return false
3480
+ if (mode === 'session' && st.toolScope?.checked && !st.toolScope.supported) { say('当前 DSH 不支持会话工具隔离,无法启动会话运行。', 'warn'); return false }
3319
3481
  let options
3320
- try { options = readRun(mode) } catch (e) { say(String(e?.message ?? e), 'warn'); return }
3482
+ try { options = readRun(mode) } catch (e) { say(String(e?.message ?? e), 'warn'); return false }
3321
3483
  runLock.current = true
3322
3484
  setBusy('start-' + mode)
3323
3485
  say('')
3486
+ let sessionId
3324
3487
  try {
3325
- let sessionId
3326
3488
  if (mode === 'session') {
3327
3489
  const created = await jpost('/pet/session-new', newSessionContext(ctx))
3328
- if (!created?.ok || !created.sessionId) throw new Error((created?.error || '没能创建会话') + (created?.hint ? ';' + created.hint : ''))
3490
+ if (!created?.ok || !created.sessionId) throw Object.assign(new Error((created?.error || '没能创建会话') + (created?.hint ? ';' + created.hint : '')), { sessionId: created?.sessionId })
3329
3491
  sessionId = created.sessionId
3330
3492
  }
3331
3493
  /* ⭐ 时段不走请求体:host 从 config 里读常设时段,手动开始只负责"现在就跑" */
3332
3494
  const body = mode === 'session'
3333
3495
  ? { mode, model: options.model, tokenBudget: options.tokenBudget, durationMinutes: options.durationMinutes, maxRounds: options.maxRounds }
3334
3496
  : { mode, model: options.model, tokenBudget: options.tokenBudget, durationMinutes: options.durationMinutes }
3335
- const r = await jpost('/pet/start', { ...body, ...(sessionId ? { sessionId } : {}) })
3497
+ const r = await jpost('/pet/start', { ...body, ...(sessionId ? { sessionId } : {}), ...(mode === 'session' && curiosityId ? { curiosityId } : {}) })
3336
3498
  if (!r?.ok || r.started === false) throw new Error((r?.error || r?.hint || '没有启动') + (r?.error && r?.hint ? ';' + r.hint : ''))
3337
3499
  if (sessionId) {
3338
3500
  try {
@@ -3342,7 +3504,8 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3342
3504
  say('会话任务已启动。', 'good')
3343
3505
  } else say('后台任务已启动。', 'good')
3344
3506
  try { const fresh = await jget('/status'); if (fresh?.ok) setSt(fresh) } catch { /* 下次轮询补上 */ }
3345
- } catch (e) { say(String(e?.message ?? e), 'warn') }
3507
+ return true
3508
+ } catch (e) { const retainedSessionId = sessionId || e?.sessionId; say(String(e?.message ?? e) + (retainedSessionId ? ';已创建的会话仍保留:' + retainedSessionId : ''), 'warn'); return false }
3346
3509
  finally { runLock.current = false; setBusy('') }
3347
3510
  }
3348
3511
  const controlRun = async (action) => {
@@ -3368,12 +3531,36 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3368
3531
  models.map((m) => h('option', { key: m.id, value: m.id }, m.label || m.id)))
3369
3532
  : h('input', { ...props, placeholder: 'provider/model;留空使用默认模型' })
3370
3533
  }
3534
+ const stopLabel = reason => ({ user: '手动停止', exit: '本段已结束', 'round-limit': '已达本趟轮数上限', 'token-budget': '本趟 token 预算已用完',
3535
+ 'total-budget': '累计 token 上限已到', 'time-limit': '已到运行时限', 'max-tokens': '输出达到本次上限', 'model-error': '模型请求失败',
3536
+ interrupted: '任务被中断', 'mode-exit': '已切出鲸鱼摸鱼模式', error: '执行出错', 'spawn-error': '后台启动失败' }[reason] || String(reason || ''))
3537
+ const runState = mode => {
3538
+ const f = st.feiyu || {}, bg = st.backgroundRun || {}, hold = st.bgWindow || {}, continuation = f.continuation || {}
3539
+ if (mode === 'session') {
3540
+ if (continuation.state === 'waiting-retry') return { label: '等待自动续刷', note: (continuation.reason === 'max-tokens' ? '输出达到本次上限,稍后自动接着刷。' : (stopLabel(continuation.reason) || '本轮暂时结束') + ',稍后自动重试续刷。') + (Number(continuation.nextAt) > 0 ? ' 预计 ' + petFmtTime(continuation.nextAt) + '。' : ''), waiting: true }
3541
+ if (continuation.state === 'needs-attention') return { label: '需要处理', note: (stopLabel(continuation.reason || f.stopReason) || '当前无法自动续刷') + ',请核对后点击「继续运行」。', warning: true }
3542
+ if (f.active && f.goal?.activation === 'disarmed') return { label: '需要处理', note: '本轮仍在收尾,但自动续刷已停用;不会继续下一轮。', warning: true }
3543
+ if (f.active) return { label: '运行中', note: f.source === 'manual' ? '正在处理当前手动刷站任务。' : '正在按当前目标继续浏览。' }
3544
+ const paused = f.goal && ['paused', 'blocked'].includes(f.goal.phase)
3545
+ const disarmed = f.goal?.activation === 'disarmed' || f.goal?.phase === 'active'
3546
+ return { label: paused ? '已暂停' : disarmed ? '续刷未启用' : '空闲',
3547
+ note: f.stopReason ? '停止原因:' + stopLabel(f.stopReason) + '。' : disarmed ? '任务记录仍在,当前没有自动续刷。点击「继续运行」后恢复。' : paused ? '当前没有继续浏览,可核对任务后继续。' : '当前没有运行中的会话刷站任务。', warning: Boolean(paused || disarmed) }
3548
+ }
3549
+ if (bg.continuationPending) return { label: '等待后台接力', note: '本段已结束,接力任务已排队' + (Number(bg.nextStartAt) > 0 ? ',预计 ' + petFmtTime(bg.nextStartAt) : '') + '。可停止接力。', waiting: true }
3550
+ if (st.bgBusy) return { label: bg.task === 'learn-review' ? '学习审核中' : '运行中', note: bg.task === 'learn-review' ? '后台正在审核学习记录,当前不是刷视频任务。' : '后台正在按本趟时限继续浏览。' }
3551
+ if (Number(hold.holdUntil) > Date.now()) return { label: hold.holdReason === 'rest' ? '自然休息中' : hold.holdReason === 'manual' ? '本时段已停止' : '自动运行已暂停',
3552
+ note: (hold.holdReason === 'rest' ? '自然休息至 ' : hold.holdReason === 'manual' ? '手动停止,暂停至 ' : '暂停至 ') + petFmtTime(hold.holdUntil)
3553
+ + (hold.holdReason === 'rest' ? ',之后在有效时段内自动恢复。' : ';点「开始后台刷」可立即恢复。') }
3554
+ return { label: '空闲', note: '当前没有运行中的后台任务。' }
3555
+ }
3371
3556
  const runPage = (mode) => {
3372
3557
  const session = mode === 'session'
3373
3558
  const options = runSettings(mode)
3374
3559
  const fy = st.feiyu || {}
3375
- const live = session ? Boolean(fy.active) : Boolean(st.bgBusy)
3560
+ const state = runState(mode)
3561
+ const live = session ? Boolean(fy.active || state.waiting) : Boolean(st.bgBusy || st.backgroundRun?.continuationPending)
3376
3562
  const tokens = fy.tokens || {}
3563
+ const windowDirty = !session && (String(options.windowStart || '') !== String(cfg.backgroundWindowStart || '') || String(options.windowEnd || '') !== String(cfg.backgroundWindowEnd || ''))
3377
3564
  return h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': mode },
3378
3565
  h('div', { 'data-bd-pet-grid': '' },
3379
3566
  h('section', { 'data-bd-sec': '' }, heading('sliders', '运行参数'),
@@ -3389,26 +3576,31 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3389
3576
  h('label', { 'data-bd-field': '', 'data-bd-grow': '' }, h('span', { 'data-bd-label': '' }, '结束(可跨午夜)'),
3390
3577
  h('input', { 'data-bd-input': '', type: 'time', value: options.windowEnd || '', onChange: (e) => updateRun(mode, 'windowEnd', e.target.value) }))),
3391
3578
  ] : []),
3579
+ windowDirty ? h('div', { 'data-bd-msg': '', 'data-warn': '', 'data-bd-window-draft': '', role: 'status' }, '连续时段有未保存草稿。请先「保存默认设置」让新时段生效;直接开始后台刷仍使用已保存的时段。') : null,
3392
3580
  h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, session ? '最多运行几分钟(0 = 不限时)' : '没填时段时,一轮刷几分钟'),
3393
3581
  h('input', { 'data-bd-input': '', type: 'number', min: session ? 0 : 1, max: session ? 1440 : 180, step: 1,
3394
3582
  value: options.durationMinutes, onChange: (e) => updateRun(mode, 'durationMinutes', e.target.value) })),
3583
+ h('details', { 'data-bd-advanced': '', 'data-bd-run-resources': '' }, h('summary', {}, '资源上限', h('span', { 'data-bd-note': '' }, ' · '
3584
+ + (Number(options.tokenBudget) > 0 ? Number(options.tokenBudget).toLocaleString() + ' token' : 'token 不限')
3585
+ + (session ? ' · 最多 ' + options.maxRounds + ' 轮' : ' · 软预算'))), h('div', {},
3395
3586
  session ? h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '最多刷多少轮'),
3396
3587
  h('input', { 'data-bd-input': '', type: 'number', min: 1, max: numLim('maxRounds', 1, 1000)[1], step: 1,
3397
3588
  value: options.maxRounds, onChange: (e) => updateRun(mode, 'maxRounds', e.target.value) })) : null,
3398
3589
  h('label', { 'data-bd-field': '' }, h('span', { 'data-bd-label': '' }, '这趟 token 预算(0 = 不限)'),
3399
3590
  h('input', { 'data-bd-input': '', type: 'number', min: 0, max: 5000000, step: 1000,
3400
- value: options.tokenBudget, onChange: (e) => updateRun(mode, 'tokenBudget', e.target.value) })),
3591
+ value: options.tokenBudget, onChange: (e) => updateRun(mode, 'tokenBudget', e.target.value) })))),
3401
3592
  h('div', { 'data-bd-note': '' }, session
3402
3593
  ? '轮数与 token 预算在每轮结束后检查;到达时限后暂停续轮。会话内的新消息按宿主中断规则处理。'
3403
3594
  : (String(options.windowStart || '') && String(options.windowEnd || '')
3404
- ? `时段任务:${options.windowStart}–${options.windowEnd} 内一直刷,一轮接一轮到时段结束;中途关掉 DSH,重新打开也会在时段内自动接着刷。手动「停止」后本时段不再自动拉起。token 预算是软目标,不能精确截断。`
3595
+ ? `${windowDirty ? '时段草稿(未生效)' : '已保存时段'}:${options.windowStart}–${options.windowEnd}。保存后的时段内会持续接力至结束;重新打开 DSH 后仍按已保存时段调度。手动「停止」后本时段不再自动拉起。token 预算是软目标,不能精确截断。`
3405
3596
  : '后台时段为空时,按单次运行时长执行;提前结束会在剩余时长内续接。后台 token 预算为软目标,不能按精确用量自动截断。')),
3406
3597
  h('div', { 'data-bd-row': '' },
3407
3598
  h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => saveRun(mode) }, '保存默认设置')),
3408
3599
  h('div', { 'data-bd-note': '' }, '两种方式分别保存。模型、时长与预算用于下一次启动;后台时段保存后生效。运行中任务不受草稿影响。')),
3409
- h('section', { 'data-bd-sec': '' }, heading('pulse', '运行状态'),
3600
+ h('section', { 'data-bd-sec': '', 'data-bd-run-state': mode }, heading('pulse', '运行状态'),
3410
3601
  h('span', { 'data-bd-badge': '', 'data-live': live ? '' : undefined },
3411
- live ? '运行中' : session && (fy.goal?.phase === 'paused' || fy.goal?.phase === 'blocked') ? '已暂停' : '空闲'),
3602
+ state.label),
3603
+ h('div', { 'data-bd-note': '', 'data-bd-run-state-note': '', role: state.warning ? 'alert' : 'status' }, state.note),
3412
3604
  session ? h('div', { 'data-bd-readout': '' },
3413
3605
  h('div', {}, h('b', {}, fy.goal ? (fy.goal.rounds || 0) + ' / ' + (fy.goal.maxRounds || 0) : fy.source === 'manual' ? '手动刷站' : '—'), h('span', {}, '已刷轮数')),
3414
3606
  h('div', {}, h('b', {}, tokens.thisRun === null || tokens.thisRun === undefined || tokens.tokensUnknown ? '未计量' : Number(tokens.thisRun).toLocaleString()), h('span', {}, '本趟 token')),
@@ -3436,10 +3628,16 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3436
3628
  catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') }
3437
3629
  } }, '停止后台任务') : null,
3438
3630
  !session && st.lastRunAt ? h('div', { 'data-bd-note': '' }, '上次运行:' + petFmtTime(st.lastRunAt)) : null,
3631
+ (() => {
3632
+ const rows = Array.isArray(st.activity) ? st.activity : []
3633
+ const last = rows.slice().reverse().find(row => ['call', 'done', 'fail'].includes(row.kind) && row.text)
3634
+ return last ? h('div', { 'data-bd-note': '', 'data-bd-last-action': '' }, '最近动作(状态快照):' + String(last.text).slice(0, 240))
3635
+ : h('div', { 'data-bd-note': '', 'data-bd-last-action': '' }, '状态快照暂无工具动作,执行明细请查看运行日志。')
3636
+ })(),
3439
3637
  h('div', { 'data-bd-note': '' }, '执行明细保存在「记录与用量」中。'),
3440
3638
  /* ⏸️ 免跑牌期间如实说(st.bgWindow.holdUntil 由宿主 /status 提供) */
3441
3639
  !session && st.bgWindow && Number(st.bgWindow.holdUntil) > Date.now()
3442
- ? h('div', { 'data-bd-note': '' }, `本时段已停止自动拉起(到 ${new Date(st.bgWindow.holdUntil).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })});点「开始后台刷」可立刻恢复。`)
3640
+ ? h('div', { 'data-bd-note': '' }, st.bgWindow.holdReason === 'rest' ? '自然休息期间不会接力;休息结束后重新检查时段。' : '暂停期间不自动拉起,手动开始可恢复。')
3443
3641
  : null)))
3444
3642
  }
3445
3643
 
@@ -3464,16 +3662,15 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3464
3662
  })),
3465
3663
  !scheduleAllDay ? h('div', { 'data-bd-row' : '' },
3466
3664
  h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '开始时间'),
3467
- h('input', { 'data-bd-input': '', type: 'time', value: scheduleStart || '18:00', onChange: (e) => { setScheduleStart(e.target.value); patch({ scheduleStart: e.target.value }, '定时开始时间') } }),
3665
+ h('input', { 'data-bd-input': '', type: 'time', value: scheduleStart || '18:00', onChange: (e) => { setScheduleStart(e.target.value); patchLatest({ scheduleStart: e.target.value }, '定时开始时间') } }),
3468
3666
  h('span', { 'data-bd-label': '' }, '结束时间'),
3469
- h('input', { 'data-bd-input': '', type: 'time', value: scheduleEnd || '09:00', onChange: (e) => { setScheduleEnd(e.target.value); patch({ scheduleEnd: e.target.value }, '定时结束时间') } })) : null,
3667
+ h('input', { 'data-bd-input': '', type: 'time', value: scheduleEnd || '09:00', onChange: (e) => { setScheduleEnd(e.target.value); patchLatest({ scheduleEnd: e.target.value }, '定时结束时间') } })) : null,
3470
3668
  h('div', { 'data-bd-row' : '' },
3471
3669
  h('span', { 'data-bd-badge': '', 'data-live': st.schedule && st.schedule.inWindow ? '' : undefined }, st.schedule && st.schedule.inWindow ? '当前在窗口内' : '当前不在窗口内'),
3472
3670
  h('span', { 'data-bd-note': '', 'data-bd-grow': '' }, st.schedule && st.schedule.lastError ? '上次失败:' + st.schedule.lastError : st.schedule && st.schedule.lastStartedAt ? '上次启动:' + petFmtTime(st.schedule.lastStartedAt) : '尚未启动'),
3473
3671
  st.schedule && st.schedule.nextRunAt ? h('span', { 'data-bd-note': '' }, '下次检查:' + petFmtTime(st.schedule.nextRunAt)) : null)) : null)
3474
3672
  const basicCards = [
3475
- () => h('div', { 'data-bd-sec': '', 'data-bd-basic-card': 'pet' },
3476
- heading('spark', '桌宠'),
3673
+ () => h('div', { 'data-bd-pet-preferences': '' },
3477
3674
  ToggleRow({
3478
3675
  label: '显示桌宠', on: !(st && st.petShow === false), disabled: Boolean(busy),
3479
3676
  note: st && st.petShow === false
@@ -3485,10 +3682,10 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3485
3682
  label: '闲聊提示', on: !(st && st.config && st.config.idleChatter === false), disabled: Boolean(busy),
3486
3683
  note: '按已配置频率显示闲聊,频率由陪伴节奏控制。',
3487
3684
  onChange: (v) => patch({ idleChatter: v }, '闲聊泡泡'),
3488
- }), h(PetCompanionSettings, { st, busy, patch })),
3489
- () => h('div', { 'data-bd-sec': '' },
3490
- heading('spark', '角色与表达'),
3491
- h('div', { 'data-bd-note': '' }, '人格与陪伴设置仅用于鲸鱼摸鱼模式。留空使用默认设定。'),
3685
+ }), h(PetCompanionSettings, { st, busy, patch, say })),
3686
+ () => h('div', { 'data-bd-sec': '', 'data-bd-basic-card': 'character' },
3687
+ heading('spark', '角色与陪伴'),
3688
+ h('div', { 'data-bd-note': '' }, '选择她陪你冲浪的方式,仅在鲸鱼摸鱼模式中生效。'),
3492
3689
  h('div', { 'data-bd-field': '' },
3493
3690
  h('span', { 'data-bd-label': '' }, '陪伴档位'),
3494
3691
  h('div', { 'data-bd-segments': '', role: 'group', 'aria-label': '陪伴档位' },
@@ -3500,24 +3697,16 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3500
3697
  h('div', { 'data-bd-row': '' },
3501
3698
  h('span', { 'data-bd-grow': '' }),
3502
3699
  h('button', { 'data-bd-btn': '', 'data-sm': '', 'data-primary': '', disabled: Boolean(busy), onClick: () => patch({ interactionStyle: interactionStyle || 'companion' }, '陪伴档位') }, '保存档位')),
3503
- h('textarea', {
3504
- 'data-bd-input': '', rows: 4, 'aria-label': '大肥鱼的人格设定', value: persona === null ? '' : persona,
3505
- placeholder: '例如:关注技术内容,表达简洁,保留个人观点。',
3506
- onChange: (e) => setPersona(e.target.value),
3507
- }),
3700
+ h('details', { 'data-bd-advanced': '', 'data-bd-companion-details': '' }, h('summary', {}, '桌宠与提醒', h('span', { 'data-bd-note': '' }, ' · '
3701
+ + (cfg.focusMode ? '专注中' : cfg.idleChatter === false ? '闲聊关闭' : ({ rare: '低频闲聊', normal: '标准闲聊', lively: '活泼闲聊' }[cfg.chatterFrequency || 'normal']))
3702
+ + (cfg.quietHoursEnabled ? ' · 安静 ' + (cfg.quietHoursStart || '23:00') + '–' + (cfg.quietHoursEnd || '08:00') : ''))), h('div', {}, basicCards[0]()))),
3703
+ () => h('div', { 'data-bd-sec': '', 'data-bd-basic-card': 'reports' },
3704
+ heading('list', '报告与参考'),
3705
+ h('div', { 'data-bd-note': '' }, '只限制留片与报告收录的精选数量,不限制继续浏览或独立互动,也不是每天要完成的数量。'),
3508
3706
  h('div', { 'data-bd-row': '' },
3509
- h('span', { 'data-bd-note': '', 'data-bd-grow': '' },
3510
- persona && persona.length > 400 ? `已写 ${persona.length} 字(每轮都占一份 token)` : ''),
3511
- h('button', {
3512
- 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy),
3513
- onClick: () => patch({ persona: String(persona || '').trim() }, '人格设定'),
3514
- }, '保存'))),
3515
- () => h('div', { 'data-bd-sec': '' },
3516
- heading('list', '限量与报告'),
3517
- h('div', { 'data-bd-row': '' },
3518
- h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '一天最多挑几条'),
3707
+ h('span', { 'data-bd-label': '', 'data-bd-grow': '' }, '每日精选上限'),
3519
3708
  h('input', {
3520
- 'data-bd-input': '', 'data-bd-num': '', type: 'number', 'aria-label': '一天最多挑几条',
3709
+ 'data-bd-input': '', 'data-bd-num': '', type: 'number', 'aria-label': '每日精选上限',
3521
3710
  min: numLim('perDay', 0, 50)[0], max: numLim('perDay', 0, 50)[1],
3522
3711
  value: perDay === null ? '5' : perDay, onChange: (e) => setPerDay(e.target.value),
3523
3712
  })),
@@ -3530,30 +3719,15 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3530
3719
  h('option', { value: 'full' }, '完整'),
3531
3720
  h('option', { value: 'brief' }, '精简'),
3532
3721
  h('option', { value: 'off' }, '不写'))),
3533
- h('div', { 'data-bd-row': '' },
3534
- h('button', {
3535
- 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy),
3536
- onClick: () => patch({ perDay: clampField('perDay', perDay, 0, 50), reportLevel: rl || 'full' }, '限量与报告'),
3537
- }, '保存'))),
3538
- () => h('div', { 'data-bd-sec': '' },
3539
- heading('dots', '长期偏好'),
3540
- h('div', { 'data-bd-note': '' }, '关键词与参考文件提供长期背景,不限制她刷什么。参考路径不改变新会话所属工作区。'),
3541
- h('textarea', {
3542
- 'data-bd-input': '', 'aria-label': '长期偏好关键词', rows: 3, placeholder: '一行一个',
3543
- value: kws === null ? '' : kws, onChange: (e) => setKws(e.target.value),
3544
- }),
3545
- h('input', {
3722
+ h('details', { 'data-bd-advanced': '', 'data-bd-reference-details': '' }, h('summary', {}, '参考文件', h('span', { 'data-bd-note': '' }, wsDir ? ' · 已设置' : ' · 未设置')), h('div', {}, h('input', {
3546
3723
  'data-bd-input': '', 'aria-label': '参考文件或目录', placeholder: '参考文件或目录(可选)',
3547
3724
  value: wsDir === null ? '' : wsDir, onChange: (e) => setWsDir(e.target.value),
3548
- }),
3725
+ }), h('div', { 'data-bd-note': '' }, '为报告提供背景资料,不改变新会话所属工作区。'))),
3549
3726
  h('div', { 'data-bd-row': '' },
3550
3727
  h('button', {
3551
- 'data-bd-btn': '', disabled: Boolean(busy),
3552
- onClick: async () => {
3553
- const list = String(kws || '').split('\n').map((s) => s.trim()).filter(Boolean)
3554
- await patch({ keywords: list, workspace: String(wsDir || '').trim() }, '长期偏好')
3555
- },
3556
- }, '保存'))),
3728
+ 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy),
3729
+ onClick: () => patch({ perDay: clampField('perDay', perDay, 0, 50), reportLevel: rl || 'full', workspace: String(wsDir || '').trim() }, '报告与参考'),
3730
+ }, '保存报告设置'))),
3557
3731
  () => {
3558
3732
  const info = st && st.pace && st.pace.explanation
3559
3733
  const saved = Number(cfg.paceFactor ?? info?.factor ?? 1)
@@ -3590,12 +3764,13 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3590
3764
  !valid ? h('div', { 'data-bd-msg': '', 'data-warn': '' }, `请填 ${lo}–${hi} 之间的数字;空白或越界草稿不会保存。`) : null,
3591
3765
  h('div', { 'data-bd-note': '' }, info?.notes.interval || '保存只影响下一次成功发送后新钉下的间隔与随机抖动;已钉下的等待不会重算。'),
3592
3766
  info ? h('div', { 'data-bd-sub': '', 'data-bd-pace-preview': '' },
3593
- h('div', { 'data-bd-h': '' }, changed ? '未保存草稿预览(不是当前倒计时)' : '间隔范围(不是当前倒计时)'),
3767
+ h('div', { 'data-bd-h': '' }, changed ? '间隔草稿与当前小时额度(不是当前倒计时)' : '当前间隔与小时额度(不是当前倒计时)'),
3594
3768
  ...Object.entries(info.kinds).map(([key, v]) => h('div', { key, 'data-bd-note': '', 'data-bd-pace-range': key },
3595
3769
  h('b', {}, v.label),
3596
3770
  `:基础 ${range(v, 1)} · 已保存 ${range(v, saved)}`,
3597
3771
  changed ? ` · 草稿 ${range(v, draft)}` : '',
3598
- ` · 每滚动小时最多 ${v.hourCap} 条`)))
3772
+ ` · 当前本地额度 ${v.hourCap} 条/滚动小时`)),
3773
+ h('div', { 'data-bd-note': '' }, '小时额度按后端当前策略显示,不随间隔倍数改变;草稿只预览间隔,实际等待见发送状态。'))
3599
3774
  : h('div', { 'data-bd-note': '' }, '节奏说明尚未就绪;不会猜基础范围,完整重启 DSH 后重试。'),
3600
3775
  h('div', { 'data-bd-row': '' },
3601
3776
  h('button', {
@@ -3615,6 +3790,8 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3615
3790
  h('div', { 'data-bd-h': '', 'data-bd-grow': '' }, icon('pulse'), h('b', {}, '它刚才在干嘛'),
3616
3791
  h('span', { 'data-bd-badge': '', 'data-live': fyLive ? '' : undefined }, fyLive ? '在刷' : '没在刷')),
3617
3792
  h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: onClearAct, disabled: Boolean(busy) }, '清屏')),
3793
+ /* ⚠️ P3-37:接口不存在时轮询已停,把话说清楚(文案对照图库页的 404 提示) */
3794
+ actRouteMissing ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'status', 'data-bd-act-missing': '' }, '活动接口不存在——可能宿主版本较旧,重启 DSH 后重试。') : null,
3618
3795
  fyLive && actLive && actLive.thinking
3619
3796
  ? h('div', { 'data-bd-thinking': '' },
3620
3797
  h('i', { 'data-bd-dot': '' }),
@@ -3740,10 +3917,32 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3740
3917
  h('span', { 'data-bd-badge': '', 'data-live': s.done ? '' : undefined }, s.done ? '✓ 已完成' : '待办'),
3741
3918
  h('span', { 'data-bd-grow': '', style: { fontWeight: s.done ? 400 : 600 } }, s.label),
3742
3919
  s.done ? null : h('button', { 'data-bd-btn': '', 'data-sm': '', onClick: () => setTab(s.view) }, '配置')))) : null,
3743
- h('div', { 'data-bd-pet-grid': '', 'data-bd-basic-grid': '' }, basicCards[1](), basicCards[3](), basicCards[0](), basicCards[2]()))
3920
+ h('div', { 'data-bd-pet-grid': '', 'data-bd-basic-grid': '' }, h(PetBrowseSettings, { st, busy, patch, say }), basicCards[1](), basicCards[2]()))
3921
+ }
3922
+ const personaCard = () => {
3923
+ const draft = String(persona === null ? cfg.persona || '' : persona)
3924
+ const saved = String(cfg.persona || '').trim()
3925
+ const changed = draft.trim() !== saved
3926
+ return h('section', { 'data-bd-sec': '', 'data-bd-persona-card': '' },
3927
+ h('div', { 'data-bd-row': '' }, heading('spark', '全局人格'),
3928
+ h('span', { 'data-bd-badge': '' }, saved ? '自定义人格' : '内置默认')),
3929
+ h('div', { 'data-bd-note': '' }, '覆盖鲸鱼摸鱼模式的聊天及所有 AI 发言,包括会话与后台刷站、评论与回复、动态和私信。普通会话不受影响;桌宠的本地状态通知使用固定文案。'),
3930
+ h('div', { 'data-bd-note': '' }, '填写后以你的全局人格为准;留空使用内置默认:活泼可爱,像群友一样接梗、善意嘴硬,严肃问题认真回答。评论和动态用自己的话,不照搬原句或写成引用解说。'),
3931
+ h('textarea', { 'data-bd-input': '', rows: 8, maxLength: 4000, 'aria-label': '大肥鱼的全局人格', value: draft,
3932
+ placeholder: '留空使用内置默认。可以完整描述性格、相处方式与表达偏好,不必提供固定台词。',
3933
+ style: { minHeight: '168px', resize: 'vertical' }, onChange: e => setPersona(e.target.value) }),
3934
+ h('div', { 'data-bd-row': '' },
3935
+ h('span', { 'data-bd-note': '', 'data-bd-grow': '', 'data-bd-persona-status': '', role: 'status' },
3936
+ changed ? '有未保存修改 · ' + draft.length + '/4000 字' : saved ? '已保存自定义人格 · 后续表达使用此设定' : '当前使用内置默认人格'),
3937
+ h('button', { 'data-bd-btn': '', 'data-primary': '', disabled: Boolean(busy) || !changed,
3938
+ onClick: async () => {
3939
+ const value = draft.trim()
3940
+ if (await patch({ persona: value }, '全局人格')) setPersona(current => current === draft ? value : current)
3941
+ } }, '保存全局人格')))
3744
3942
  }
3745
3943
  const speechPage = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'speech' },
3746
- pageHead('互动与投递', '管理公开发言、私信投递、自动回复和发送限制。'),
3944
+ pageHead('互动与投递', '管理全局人格、公开发言、私信投递、自动回复和发送限制。'),
3945
+ personaCard(),
3747
3946
  h(PetSendReviewPanel, { st, patch, busy, say }),
3748
3947
  h('div', { 'data-bd-pet-grid': '' },
3749
3948
  h('section', { 'data-bd-sec': '' }, heading('spark', '公开发言'), ToggleRow({
@@ -3823,7 +4022,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3823
4022
  onChange: (v) => patch({ dmReplyFollowedOnly: v }, '仅回复已关注的用户'),
3824
4023
  }))) : null,
3825
4024
  h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => patch({ dmMode: dmMode || 'off', dmUid: String(dmUid || '').trim() }, '私信投递设置') }, '保存投递设置')),
3826
- basicCards[4](), h('section', { 'data-bd-sec': '' }, heading('pulse', '发送状态'), (() => {
4025
+ basicCards[3](), h('section', { 'data-bd-sec': '' }, heading('pulse', '发送状态'), (() => {
3827
4026
  const pc = st && st.pace
3828
4027
  if (!pc || pc.ok === false) return h('div', { 'data-bd-note': '' }, '发送节奏读取失败。可重启 DSH 后重新加载。')
3829
4028
  const cd = pc.cooldown
@@ -3926,39 +4125,53 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3926
4125
  const f = st?.feiyu || {}
3927
4126
  const bg = st?.backgroundRun || {}
3928
4127
  const sessionOn = Boolean(f.active)
3929
- const backgroundOn = Boolean(st?.bgBusy)
4128
+ const backgroundOn = Boolean(st?.bgBusy || bg.continuationPending)
3930
4129
  const windowOn = Boolean(st?.bgWindow?.start && st?.bgWindow?.end)
3931
- const active = sessionOn || backgroundOn
3932
- const mode = sessionOn ? '当前会话' : backgroundOn ? (bg.task === 'learn-review' ? '后台学习审核' : '后台连续冲浪') : windowOn ? '定时窗口' : '未安排'
4130
+ const sessionState = runState('session'), backgroundState = runState('background')
4131
+ const waitingSession = Boolean(sessionState.waiting)
4132
+ const active = sessionOn || backgroundOn || waitingSession
4133
+ const holdOn = Number(st?.bgWindow?.holdUntil) > Date.now()
4134
+ const scheduleState = st.schedule || {}
4135
+ const scheduleNext = c.scheduleEnabled ? scheduleState.lastError ? '定时检查失败:' + scheduleState.lastError
4136
+ : Number(scheduleState.nextRunAt) > 0 || scheduleState.nextRunAt ? '下次定时检查:' + petFmtTime(scheduleState.nextRunAt)
4137
+ : scheduleState.inWindow ? '当前在每日定时窗口内,等待宿主检查;每天启动一次。' : '等待进入每日定时窗口。' : ''
4138
+ const mode = sessionOn || waitingSession ? '当前会话' : backgroundOn ? (bg.task === 'learn-review' ? '后台学习审核' : '后台连续冲浪') : holdOn ? backgroundState.label
4139
+ : windowOn ? '连续时段已安排' : c.scheduleEnabled ? '每日定时已安排' : '未安排'
4140
+ const next = sessionOn || waitingSession ? sessionState.note : backgroundOn ? backgroundState.note : holdOn ? backgroundState.note
4141
+ : windowOn ? (st.bgWindow.active ? '当前在连续时段内,等待后台调度。' : '到已保存的连续时段后自动开始。') : scheduleNext || '等待手动开始'
3933
4142
  const schedule = c.scheduleEnabled ? (c.scheduleAllDay ? '全天检查' : `${c.scheduleStart || '18:00'}–${c.scheduleEnd || '09:00'}`) : '未开启'
3934
- const selectedBackground = backgroundOn || (!sessionOn && runMode === 'background')
4143
+ const selectedBackground = backgroundOn || (!sessionOn && !waitingSession && runMode === 'background')
3935
4144
  const budgetValue = sessionOn ? f.tokens?.budget : backgroundOn ? bg.tokenBudget : selectedBackground ? c.backgroundTokenBudget : c.sessionTokenBudget
3936
4145
  const budget = Number(budgetValue) > 0 ? Number(budgetValue).toLocaleString() + ' token / 趟' : '本趟不限(受总闸约束)'
3937
4146
  return h('section', { 'data-bd-sec': '', 'data-bd-run-plan': '' },
3938
- h('div', { 'data-bd-row': '' }, h('div', { 'data-bd-h': '', 'data-bd-grow': '' }, icon('pulse'), h('b', {}, '运行计划')), h('span', { 'data-bd-badge': '', 'data-live': active ? '' : undefined }, active ? '正在执行' : '待命')),
4147
+ h('div', { 'data-bd-row': '' }, h('div', { 'data-bd-h': '', 'data-bd-grow': '' }, icon('pulse'), h('b', {}, '运行计划')), h('span', { 'data-bd-badge': '', 'data-live': active ? '' : undefined }, sessionOn || st.bgBusy ? '正在执行' : active ? '等待续刷' : '待命')),
3939
4148
  h('div', { 'data-bd-run-plan-grid': '' },
3940
4149
  h('div', {}, h('span', {}, '现在'), h('strong', {}, mode)),
3941
- h('div', {}, h('span', {}, '下一步'), h('strong', {}, sessionOn ? '按当前目标续轮' : backgroundOn ? '按时段或本趟时限运行' : Number(st?.bgWindow?.holdUntil) > Date.now() ? '本时段已手动停止自动拉起' : windowOn ? '到窗口时自动接力' : '等待手动开始')),
4150
+ h('div', {}, h('span', {}, '下一步'), h('strong', {}, next)),
3942
4151
  h('div', {}, h('span', {}, '模型'), h('strong', {}, sessionOn ? f.modelActive || f.model || '当前会话模型' : backgroundOn ? bg.model || '后台模型' : (selectedBackground ? c.backgroundModelId : c.sessionModelId) || '沿用宿主默认')),
3943
4152
  h('div', {}, h('span', {}, '预算'), h('strong', {}, budget)),
3944
4153
  h('div', {}, h('span', {}, '定时'), h('strong', {}, schedule)),
3945
4154
  h('div', {}, h('span', {}, '完成反馈'), h('strong', {}, selectedBackground ? '桌宠提醒 + 运行日志' : '当前会话 + 运行日志'))),
3946
- h('div', { 'data-bd-note': '' }, active ? '这项任务不会因为切换设置页而停止;后台任务完成或失败后会回写结果。' : '当前会话、后台连续刷、定时窗口分别保存,互相不会覆盖参数。'))
4155
+ scheduleNext ? h('div', { 'data-bd-note': '', 'data-bd-schedule-plan': '', role: scheduleState.lastError ? 'alert' : 'status' }, scheduleNext) : null,
4156
+ h('div', { 'data-bd-note': '' }, active ? '切换设置页不会停止任务。等待续刷与接力也可用暂停或停止按钮取消。' : '当前会话、后台连续刷、定时窗口分别保存,互相不会覆盖参数。'))
3947
4157
  }
3948
4158
  const runView = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'run' },
3949
4159
  pageHead('运行方式', '会话运行、后台运行与定时计划。'),
3950
4160
  st.toolScope?.checked && !st.toolScope.supported ? h('div', { 'data-bd-msg': '', 'data-warn': '', role: 'alert' }, '当前 DSH 不支持会话工具隔离。会话运行不可用,后台运行和本地管理仍可使用。') : null,
3951
4161
  h('div', { 'data-bd-run-launches': '' }, ...['session', 'background'].map(mode => {
3952
4162
  const isSession = mode === 'session', options = runSettings(mode)
3953
- const active = isSession ? Boolean(st.feiyu?.active) : Boolean(st.bgBusy)
4163
+ const state = runState(mode)
4164
+ const active = isSession ? Boolean(st.feiyu?.active || state.waiting) : Boolean(st.bgBusy || st.backgroundRun?.continuationPending)
3954
4165
  const title = isSession ? '新开会话刷' : '后台刷'
3955
4166
  return h('section', { key: mode, 'data-bd-run-launch': mode },
3956
- h('div', { 'data-bd-row': '' }, icon(isSession ? 'play' : 'moon'), h('h2', { 'data-bd-grow': '' }, title), h('span', { 'data-bd-badge': '', 'data-live': active ? '' : undefined }, active ? '运行中' : '未运行')),
4167
+ h('div', { 'data-bd-row': '' }, icon(isSession ? 'play' : 'moon'), h('h2', { 'data-bd-grow': '' }, title), h('span', { 'data-bd-badge': '', 'data-live': active ? '' : undefined }, state.label)),
3957
4168
  h('p', {}, isSession ? '在独立会话中运行,保留对话与执行进度。' : '在后台执行,支持连续时段与每日定时计划。'),
4169
+ state.warning || state.waiting || !isSession && Number(st.bgWindow?.holdUntil) > Date.now() ? h('div', { 'data-bd-note': '' }, state.note) : null,
3958
4170
  h('div', { 'data-bd-launch-meta': '' }, h('span', { title: options.model || '' }, options.model || (isSession ? '当前会话模型' : '宿主默认模型')), h('span', {}, Number(options.tokenBudget) > 0 ? Number(options.tokenBudget).toLocaleString() + ' token' : '不设单次 token 上限')),
3959
4171
  h('button', { 'data-bd-btn': '', 'data-primary': '', 'data-bd-launch-action': mode, disabled: Boolean(busy) || active || (isSession && st.toolScope?.checked && !st.toolScope.supported),
3960
4172
  onClick: () => startRun(mode) }, busy === 'start-' + mode ? '正在启动…' : isSession ? '新开会话刷' : '开始后台刷'))
3961
4173
  })),
4174
+ h(PetCuriosityJournal, { st, busy, onExplore: curiosityId => { setRunMode('session'); return startRun('session', { curiosityId }) } }),
3962
4175
  h('div', { 'data-bd-segments': '', role: 'group', 'aria-label': '运行方式' }, ...[['session','会话参数'], ['background','后台与定时']].map(([mode, label]) => h('button', { key: mode, 'aria-pressed': runMode === mode, onClick: () => setRunMode(mode) }, label))),
3963
4176
  runPage(runMode), runMode === 'background' ? scheduleCard() : null,
3964
4177
  h('details', { 'data-bd-run-details': '', open: Boolean(st.recovery?.needsAction) }, h('summary', {}, '运行计划与恢复'), runPlan(), weeklyRecoveryCard({ showReview: false })))
@@ -3984,7 +4197,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
3984
4197
  h('span', { 'data-bd-grow': '' }),
3985
4198
  st.feiyu?.active ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: () => controlRun('stop') }, '暂停会话') : null,
3986
4199
  st.bgBusy ? h('button', { 'data-bd-btn': '', disabled: Boolean(busy), onClick: async () => { setBusy('stop-bg'); try { const r = await jpost('/pet/stop', {}); if (!r?.ok) throw new Error(r?.error || '停止失败'); say('已请求停止后台任务', 'good') } catch (e) { say(String(e?.message ?? e), 'warn') } finally { setBusy('') } } }, '停止后台任务') : null),
3987
- logMode === 'events' ? settingLogCard() : basicCards[5]())
4200
+ logMode === 'events' ? settingLogCard() : basicCards[4]())
3988
4201
  const videoPage = () => h('div', { 'data-bd-page-content': '', 'data-bd-settings-page': 'video' },
3989
4202
  h('div', { 'data-bd-pghead': '' }, icon('film'), h('div', {}, h('b', {}, '视频工作室'),
3990
4203
  h('p', {}, '视频制作、制作记录与本地成片。'))), pvCard())
@@ -4058,7 +4271,7 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
4058
4271
  }
4059
4272
 
4060
4273
  const StablePetSettingsModal = typeof React.memo === 'function' ? React.memo(PetSettingsModal, (a, b) =>
4061
- a.ctx === b.ctx && a.onClose === b.onClose && a.toast === b.toast && a.initialTab === b.initialTab
4274
+ a.ctx === b.ctx && a.onClose === b.onClose && a.toast === b.toast && a.initialTab === b.initialTab && a.initialRunMode === b.initialRunMode
4062
4275
  && a.status?.[0] === b.status?.[0] && a.status?.[1] === b.status?.[1] && a.status?.[2] === b.status?.[2]) : PetSettingsModal
4063
4276
 
4064
4277
  /* 🐾 桌宠本体:状态灯 / 拖拽 / 弹菜单 / 新开会话刷(B 流程)都在这里编排。
@@ -4067,10 +4280,11 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
4067
4280
  function PetRoot({ ctx }) {
4068
4281
  const [st, setSt, fails] = useStatus(5000)
4069
4282
  const [pos, setPos] = React.useState(null) // null = 还没从 localStorage/默认位初始化
4070
- const [panel, setPanel] = React.useState(null) // null | menu | bg | log | status | settings(互斥)
4283
+ const [panel, setPanel] = React.useState(null) // null | menu | status | settings(互斥)
4071
4284
  const [dragging, setDragging] = React.useState(false)
4072
4285
  const [sessBusy, setSessBusy] = React.useState(false)
4073
4286
  const [settingsTab, setSettingsTab] = React.useState('run')
4287
+ const [settingsRunMode, setSettingsRunMode] = React.useState('session')
4074
4288
  const [bubbles, setBubbles] = React.useState([])
4075
4289
  const [toasts, toast] = useToasts()
4076
4290
  const drag = React.useRef(null)
@@ -4236,18 +4450,19 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
4236
4450
  try {
4237
4451
  let r1 = null
4238
4452
  try { r1 = await jpost('/pet/session-new', newSessionContext(ctx)) }
4239
- catch (e) { toast('创建会话失败:' + String(e?.message ?? e), 'warn'); return }
4453
+ catch (e) { toast('创建会话失败:' + String(e?.message ?? e) + (e?.sessionId ? ';已创建的会话仍保留:' + e.sessionId : ''), 'warn'); return }
4240
4454
  if (!r1 || r1.ok === false) {
4241
- toast('创建会话失败:' + ((r1 && r1.error) || '未知原因') + (r1 && r1.hint ? ';' + r1.hint : ''), 'warn')
4455
+ toast('创建会话失败:' + ((r1 && r1.error) || '未知原因') + (r1 && r1.hint ? ';' + r1.hint : '') + (r1?.sessionId ? ';已创建的会话仍保留:' + r1.sessionId : ''), 'warn')
4242
4456
  return
4243
4457
  }
4244
4458
  /* 切过去看它刷:uiWorkspace 是宿主服务,可能没有/名字可能不同 ⇒ 全程防御式;
4245
4459
  * 切不过去也不拦开刷 —— 会话照样开了,只是没自动跳过去。 */
4460
+ let opened = false
4246
4461
  try {
4247
4462
  const ws = (typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null)
4248
- if (ws && typeof ws.openSession === 'function') ws.openSession(r1.sessionId)
4463
+ if (ws && typeof ws.openSession === 'function') { await ws.openSession(r1.sessionId); opened = true }
4249
4464
  } catch { /* 切换会话失败不影响开刷 */ }
4250
- toast('已开会话,鱼进去了', 'good')
4465
+ toast(opened ? '已开会话,鱼进去了' : '会话已创建(' + r1.sessionId + '),无法自动打开时可从侧栏进入。', opened ? 'good' : 'info')
4251
4466
  let r2 = null
4252
4467
  try { r2 = await jpost('/pet/start', { mode: 'session', sessionId: r1.sessionId }) }
4253
4468
  catch (e) { toast('开刷没成:' + String(e?.message ?? e), 'warn'); return }
@@ -4421,10 +4636,10 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
4421
4636
  item('session', 'play', '新开会话刷',
4422
4637
  sessBusy ? '正在开会话…' : `${modelTxt} · 预算 ${petFmtWan(cfg.sessionTokenBudget ?? cfg.tokenBudget)}`,
4423
4638
  () => { setPanel(null); runNewSession() }, sessBusy, sessBusy),
4424
- item('background', 'moon', '后台刷', '运行时长与定时参数', () => setPanel('bg')),
4639
+ item('background', 'moon', '后台刷', '运行时长与定时参数', () => { setSettingsTab('run'); setSettingsRunMode('background'); setPanel('settings') }),
4425
4640
  item('logs', 'list', '运行记录', '', () => { setSettingsTab('logs'); setPanel('settings') }),
4426
4641
  item('status', 'pulse', '运行状态', '', () => setPanel('status')),
4427
- item('settings', 'gear', '工作台', '', () => { setSettingsTab('run'); setPanel('settings') }),
4642
+ item('settings', 'gear', '工作台', '', () => { setSettingsTab('run'); setSettingsRunMode('session'); setPanel('settings') }),
4428
4643
  h('hr', { 'data-bd-pet-sep': '' }),
4429
4644
  item('hide', 'hide', '隐藏桌宠', '', hidePet),
4430
4645
  h('footer', { 'data-bd-menu-token': '' },
@@ -4466,10 +4681,8 @@ function PetOtherSettings({ view = 'environment', st, setSt, busy, setBusy, say,
4466
4681
  同时 [data-bd-pet][data-align] / [data-flip] 这些选择器也要求卡片是它的后代。 */
4467
4682
  panel === 'menu' ? menuCard : null,
4468
4683
  panel === 'menu' && preview ? h(PetMenuPreview, { key: preview.kind, kind: preview.kind, anchor: preview.anchor, st, fails, tokenValue, tokenNote, onEnter: holdPreview, onLeave: leavePreview }) : null,
4469
- panel === 'bg' ? h(PetBgPanel, { st, toast, onDone: () => setPanel('log') }) : null,
4470
- panel === 'log' ? h(PetLogPanel, { toast }) : null,
4471
4684
  panel === 'status' ? h(PetStatusCard, { st, fails }) : null,
4472
- panel === 'settings' ? h(StablePetSettingsModal, { onClose: closeSettings, toast, ctx, status: [st, setSt, fails], initialTab: settingsTab }) : null,
4685
+ panel === 'settings' ? h(StablePetSettingsModal, { onClose: closeSettings, toast, ctx, status: [st, setSt, fails], initialTab: settingsTab, initialRunMode: settingsRunMode }) : null,
4473
4686
  h('div', { key: 'toast', 'data-bd-pet-toast': '', role: 'status', 'aria-live': 'polite', 'aria-atomic': false },
4474
4687
  toasts.map((t) => h('div', { key: t.id, 'data-tone': t.tone || undefined }, t.text))),
4475
4688
  ]))