@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +189 -7
  2. package/README.zh-CN.md +189 -7
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/FRONTEND-CO-CREATION.md +191 -0
  5. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  6. package/docs/HANDOFF-CRITERIA.md +92 -0
  7. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  8. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  9. package/docs/PROMO-PROMPT-3.0.md +100 -0
  10. package/docs/USER-GUIDE.en.md +58 -3
  11. package/docs/USER-GUIDE.zh-CN.md +59 -4
  12. package/docs/WHITEPAPER.md +207 -0
  13. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  14. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  15. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  16. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  17. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  18. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  19. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  20. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  21. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  22. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  23. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  24. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  25. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  26. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  27. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  28. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  29. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  30. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  31. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  32. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  33. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  34. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  35. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  36. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  37. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  38. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  39. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  40. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  41. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  42. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  43. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  44. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  45. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  46. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  47. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  48. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  49. package/docs/internal/KICKOFF-P0.md +254 -0
  50. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  51. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  52. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  53. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  54. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  55. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  56. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  57. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  58. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  59. package/docs/internal/PROGRESS-20260917.md +93 -0
  60. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  61. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  62. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  63. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  64. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  65. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  66. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  67. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  68. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  69. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  70. package/docs/internal/RESUME-20260918.md +171 -0
  71. package/docs/internal/RESUME-20260919.md +104 -0
  72. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  73. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  74. package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
  75. package/docs/internal/ROADMAP.md +106 -0
  76. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  77. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
  78. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  79. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  80. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  81. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  82. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  83. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  84. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  85. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  86. package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
  87. package/docs/internal/TODO-BACKLOG.md +263 -142
  88. package/docs/internal/TODO-GRAPH.html +715 -0
  89. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  90. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  91. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  92. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  93. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  94. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  95. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  96. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  97. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  98. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  99. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  100. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  101. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  102. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  103. package/docs/prompts/M8-3-enable-verify.md +49 -49
  104. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  105. package/lib/acceptance.js +71 -0
  106. package/lib/activation-host.js +153 -18
  107. package/lib/activation-inbox.js +25 -7
  108. package/lib/board-mode.js +30 -0
  109. package/lib/client.js +1758 -90
  110. package/lib/config-io.js +156 -0
  111. package/lib/context-bridge.js +5 -2
  112. package/lib/context-host.js +86 -15
  113. package/lib/degrade.js +385 -0
  114. package/lib/dsh-home.js +143 -0
  115. package/lib/engine-identity.js +149 -0
  116. package/lib/engine-switch.js +247 -0
  117. package/lib/episodic-store.js +63 -12
  118. package/lib/evidence-store.js +10 -3
  119. package/lib/fact-store.js +22 -3
  120. package/lib/fs-retry.js +46 -0
  121. package/lib/index-sync.js +13 -1
  122. package/lib/index.js +3446 -263
  123. package/lib/intent-clean-safe.js +258 -0
  124. package/lib/intent-clean.js +12 -16
  125. package/lib/l0-extract.js +478 -149
  126. package/lib/l0-index-sync.js +195 -0
  127. package/lib/l0-index.js +349 -239
  128. package/lib/ledger-criteria.js +142 -0
  129. package/lib/m4-corpus.js +8 -2
  130. package/lib/m7-index-sync-host.js +73 -5
  131. package/lib/m7-wire.js +3 -3
  132. package/lib/memory-anchor.js +56 -1
  133. package/lib/memory-envelope.js +257 -0
  134. package/lib/memory-hub.js +138 -13
  135. package/lib/memory-index.js +4 -2
  136. package/lib/memory-mutation.js +246 -0
  137. package/lib/memory-writer.js +204 -24
  138. package/lib/note-status-apply.js +118 -0
  139. package/lib/note-status.js +196 -0
  140. package/lib/procedure-observation.js +48 -0
  141. package/lib/procedure-store.js +118 -20
  142. package/lib/python-setup.js +1 -1
  143. package/lib/python-sidecar-client.js +29 -3
  144. package/lib/recall-fusion.js +83 -12
  145. package/lib/rerank-host.js +160 -0
  146. package/lib/rules-edit.js +159 -0
  147. package/lib/rules-layer.js +261 -0
  148. package/lib/semantic-decide.js +41 -8
  149. package/lib/semantic-js.js +66 -6
  150. package/lib/shadow-host.js +3 -5
  151. package/lib/shadow-retrieval.js +3 -3
  152. package/lib/skill-export-host.js +153 -0
  153. package/lib/skill-export.js +239 -0
  154. package/lib/state-commit.js +245 -0
  155. package/lib/storage-manage.js +6 -0
  156. package/lib/subagent-gc.js +4 -8
  157. package/lib/temporal-parse.js +191 -159
  158. package/lib/tier-layer-inject.js +650 -0
  159. package/lib/tier0-catalog.js +735 -0
  160. package/lib/water-window.js +263 -186
  161. package/lib/wb-contract.js +691 -0
  162. package/lib/wb-sidecar.js +890 -0
  163. package/lib/ws-overview-rank.js +2 -2
  164. package/package.json +1 -1
  165. package/python/m7_embedding_v1.py +5 -5
  166. package/python/worker_semantic_v1.py +17 -6
  167. package/python/worker_v1.py +38 -4
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 3. settings.section — 自动记忆设置页(存储位置、注入、反思风格)
8
8
  * Data flows over /api/dsh-auto-memory/* (loopback-only host routes).
9
9
  */
10
- console.log('[dsh-auto-memory] client v2.5.3 fingerprint: per-session-workspace')
10
+ console.log('[dsh-auto-memory] client v3.0.1 fingerprint: index-self-heal')
11
11
  window.__ModuleLoader__.load({
12
12
  id: '@a9i5k4/dsh-auto-memory',
13
13
  factory: (require) => {
@@ -15,6 +15,18 @@ window.__ModuleLoader__.load({
15
15
  var exports = module.exports
16
16
  var React = require('react')
17
17
  var h = React.createElement
18
+ // react-dom 的 createPortal(2026-09-16): 抽屉要浮在整页之上, 必须挂到 body ——
19
+ // 挂在看板容器内的话, 页面往下滚它就被推出视口, 用户「点开的页签看不见」。
20
+ // 这是 dsh-context 的同类做法(它也用 createPortal 渲染居中弹窗)。
21
+ // require 失败时降级为 null ⇒ 抽屉退回容器内渲染(功能不丢, 只是不能浮)。
22
+ var createPortal = null
23
+ try { createPortal = require('react-dom').createPortal } catch (e) { createPortal = null }
24
+ /** 把节点送进 body —— 浮窗必须挂在 body 才能浮在整页之上且不随页面滚动跑出视口。
25
+ * createPortal 不可用时原样返回(退回常规渲染, 功能不丢)。 */
26
+ function kxPortal(node) {
27
+ if (!createPortal || typeof document === 'undefined' || !document.body) return node
28
+ return createPortal(node, document.body)
29
+ }
18
30
  var useState = React.useState
19
31
  var useEffect = React.useEffect
20
32
  var useReducer = React.useReducer
@@ -225,6 +237,39 @@ window.__ModuleLoader__.load({
225
237
  empty: '(空)', back: '← 返回', view: '查看',
226
238
  clickDateViewLog: '点击日期查看当日日志(append-only):',
227
239
  userMemoryBlock: '用户记忆(用户画像 · 跨项目):', userMemoryEmpty: '(空)', userMemoryTruncated: '(内容过长,已截断显示)',
240
+ // ★R7:硬性约束编辑面板文案
241
+ // ★R1–R6:审批界面可读性文案
242
+ hubStageObserved: '刚观察到',
243
+ hubStageCandidate: '够格待审',
244
+ hubStageValidated: '已通过验证',
245
+ hubStageActive: '已启用',
246
+ hubStageDeprecated: '已弃用',
247
+ hubWhyTitle: '为什么还不能晋升:',
248
+ hubWhyCanPromote: '条件已满足,可以晋升。',
249
+ hubWhyDiversity: function (d, need) { return '需要 ' + need + ' 个不同会话都用过它,目前只有 ' + d + ' 个 —— 换个场景再用一次它就能推进。' },
250
+ hubWhySuccess: function (n, need) { return '需要成功 ' + need + ' 次,目前 ' + n + ' 次 —— 再成功用一次它会自动变好。' },
251
+ hubWhyCorrectionRate: function (r, cap) { return '它的纠正率是 ' + r + ',超过了上限 ' + cap + ' —— 说明这套流程本身有问题,需要先改掉。' },
252
+ hubWhyHasCorrection: function (n) { return '它被纠正过 ' + n + ' 次 —— 有纠正记录说明这流程是错的,不能固化。' },
253
+ hubWhyNoCriteria: '它没写「怎么算成功」—— 没有验收标准的流程不算技能。',
254
+ hubWhyHighRisk: '它是高风险操作,需要你确认后才会固化。',
255
+ hubWhyObservationOnly: '它只是「看到过一件事」的线索,不是真正的流程 —— 不会自动晋升。',
256
+ hubEvSeen: '见过', hubEvSuccess: '成功', hubEvSessions: '跨会话', hubEvReuse: '被复用', hubEvCorrection: '被纠正',
257
+ hubEvLine: function (ev) { return '见过 ' + (ev.seen || 0) + ' 次 · 成功 ' + (ev.success || 0) + ' 次 · 在 ' + (ev.sessions || 0) + ' 个会话里用过' + ((ev.correction || 0) ? ' · 被纠正过 ' + ev.correction + ' 次' : '') },
258
+ hubInjectPreview: '晋升后会注入这些(点开看)',
259
+ hubInjectCriteria: '验收标准:',
260
+ hubNoSteps: '(这条没有可用步骤——源头是机械截断,不是真流程)',
261
+ rulesTitle: '硬性约束(每轮必注入)',
262
+ rulesIntro: '这些条目每一轮都会原封不动发给我,优先级高于其它内容。它不走检索、不会自动淘汰,所以过时的、或 AI 自己加错的,都要你手动改。',
263
+ rulesPath: '来源文件:',
264
+ rulesEmpty: '(暂无条目)',
265
+ rulesSourceUser: '你写的',
266
+ rulesSourceAi: 'AI 加的',
267
+ rulesEdit: '编辑', rulesSave: '保存', rulesCancel: '取消', rulesDelete: '删除',
268
+ rulesConfirmDelete: '确认删除这条约束?\n\n删除后无法撤销——它下一轮就不再注入了。',
269
+ rulesAddHint: '新增一条约束:',
270
+ rulesAddPlaceholder: '例:未经我确认,不要自动修改生产环境的文件。',
271
+ rulesAdd: '添加',
272
+ rulesPreviewHint: '预览:下一轮注入时会变成这样',
228
273
  appended: '已追加', notesPathLabel: '项目笔记: ',
229
274
  notesPlaceholder: '想追加到项目笔记的内容…(保存时自动带日期标题)',
230
275
  saving: '保存中…', append: '追加',
@@ -252,8 +297,9 @@ window.__ModuleLoader__.load({
252
297
  fProjectDir: '项目记忆目录', fProjectDirHint: '相对各工作区的目录名(默认 .dsh-memory)。',
253
298
  fInject: '注入记忆上下文', fInjectHint: '每次组装提示词时自动注入 <memory_system> 块。',
254
299
  fBudget: '注入预算(字符)', fBudgetHint: '记忆块总预算,超出部分截断。默认 1600(≈400-600 token/轮);活跃会话每轮都注入这段背景,调低更省 token,调高保留更多记忆。',
255
- fNoteCap: '项目笔记容量上限(字符)', fNoteCapHint: '项目笔记 MEMORY.md 的容量上限,默认 12000。超出时自动整理:先把较早内容交给模型折叠成要点,失败则退回整条归档(原文进 archive,不丢)——新记忆不会被堵在外面。注意与上面的「注入预算」不是一回事:这个管文件本体大小,注入预算管每轮往上下文塞多少摘要。',
256
- fUserCap: '用户级记忆容量上限(字符)', fUserCapHint: '用户级 MEMORY.md(跨项目规则)的容量上限,默认 12000。整理方式与项目笔记相同(先折叠成要点,必要时整条归档)。',
300
+ fExclude: '排除来源(每行一条)', fExcludeHint: '这些来源不再进注入,避免坏记忆反复灌入把结果一路带偏。四种写法:记忆 id(mem_xxx)、整层(log/whiteboard/project/user/reflection)、目录前缀(以 / 或 \\ 结尾)、精确文件路径。与 supersede 互补:supersede 管"被新版替代",这里管"整条来源不可信"。被挡下的条数会在注入里以 [降级] 如实标注。',
301
+ fNoteCap: '项目笔记容量上限(字符)', fNoteCapHint: '项目笔记 MEMORY.md 的容量上限,默认 24000。超出时自动整理:先把较早内容交给模型折叠成要点,失败则退回整条归档(原文进 archive,不丢)——新记忆不会被堵在外面。注意与上面的「注入预算」不是一回事:这个管文件本体大小,注入预算管每轮往上下文塞多少摘要。',
302
+ fUserCap: '用户级记忆容量上限(字符)', fUserCapHint: '用户级 MEMORY.md(跨项目规则)的容量上限,默认 24000。整理方式与项目笔记相同(先折叠成要点,必要时整条归档)。',
257
303
  fSnapGap: '快照最小间隔(轮)', fSnapGapHint: '动态记忆快照内容变化后,至少隔 N 轮才重新注入,避免每轮日志微变都追加快照导致历史膨胀。默认 5;0=每轮都尝试(仍受内容变化约束)。',
258
304
  fReinjectOnCompact: '压缩后立即重注入快照', fReinjectOnCompactHint: '上下文被压缩/截断(通常伴随 contextVersion 重置)后,快照会被清掉;开启后强制立即重注入一次,确保记忆背景重建。默认开。',
259
305
  fPromptCustom: '自定义记忆注入 prompt', fPromptCustomHint: '可覆盖各层提示文案(小众功能)。支持占位符 {date} {ws} {budget} {n}。改坏了可一键恢复默认。',
@@ -271,6 +317,8 @@ window.__ModuleLoader__.load({
271
317
  awayTitle: '欢迎回来', awayMsg: '你离开的这段时间,我已经帮你把日志整理好了。记忆窗口已打开,可以看看这段时间的状况。',
272
318
  sumTitle: '时段总结',
273
319
  fReflect: '每日反思', fReflectHint: '昨天有工作日志时,会话首轮主动呈现昨日反思。',
320
+ fAutoContinue: '自动接续', fAutoContinueHint: '水位达到阈值时弹出确认卡,同意即接续到新会话(沿用工作区/模型/思考档位)。出厂默认关闭(功能仍在测试期);本开关独立于交接白板——关白板不影响水位测量与接续资格。',
321
+ handoffSwitchTitle: '白板与自动接续开关', handoffSwitchHint: '两个开关出厂默认均为关闭(功能仍在测试期);此处与「设置 → 上下文」读写同一对配置键,任一处改动即全局生效。', handoffOn: '交接白板:开', handoffOff: '交接白板:关',
274
322
  fHandoff: '交接白板', fHandoffHint: 'PLAN.md 全貌快照 + 四段式交接账本:模型在理解全貌/阶段完成时写入,注入动态快照首位,面板「白板」页签可实时查看,跨上下文窗口续命。',
275
323
  fWaterWindow: '水位估计窗口(token)', fWaterWindowHint: '会话消息按官方公式(4 字符≈1 token)估算达到该值即视为上下文将满;0=关闭。默认 65536(128K 窗口的保守半量),按所用模型调整。',
276
324
  fWaterAdvisory: '水位交接建议', fWaterAdvisoryHint: '水位越阈时在动态快照注入交接建议(写账本/刷新白板/建议开新窗);无人值守时静默。',
@@ -284,7 +332,7 @@ window.__ModuleLoader__.load({
284
332
  fMaintSchedule: '定时 30 天蒸馏', fMaintScheduleHint: '每天到点自动把超过 30 天的旧日志蒸馏归档(等价 memory_maintain);无旧日志时零成本跳过。',
285
333
  fMaintScheduleTime: '蒸馏触发时间', fMaintScheduleTimeHint: 'HH:MM,默认 10:00(与固化时间错开)。',
286
334
  waterCardTitle: '上下文水位', waterAutoSrc: '自动检测', waterManualSrc: '手动设定', waterOfficialSrc: '官方路由容量', waterFallbackSrc: '回退默认值', waterNotMeasured: '本会话尚未测量(发送一轮后自动更新)', waterMeterOfficial: '官方计量', waterMeterHeuristic: '启发式降级', waterThresholdHint: '水位达到 {t} 时注入交接建议并自动补写账本;计量优先用官方 token-meter(与聊天框 context ring 同源),不可用时降级启发式估算;窗口优先取官方路由容量(request/context 的 contextWindow),其次按当前模型查 settings.yaml contextWindow,也可在下方手动覆盖;触发分母=官方声明窗口(provider 自报过硬限时取较小值),预留输出不参与扣减。',
287
- autoContTitle: '自动接续', autoContHint: '开启后:水位达到阈值、且旧会话已空闲(harness 权威 running 位在轮次边界转为 false,取代旧「两轮水位持平」)时,弹出确认卡——同意=立即接续,拒绝=本轮跳过(同一边界不再提示),30-40 秒无操作视为挂机自动接续;接续前先让旧 Agent 刷新白板 PLAN 与交接账本,再建新会话(沿用工作区/模型/思考档位)并注入分层交接材料;触发后默认 30 分钟内不重复。', autoContOn: '自动接续:开', autoContOff: '自动接续:关', autoContThreshold: '阈值', autoContCountdown: '⏳ {s} 秒后自动接续…', autoContCancel: '取消', autoContConfirm: '本会话上下文已用 {p}%(按官方声明窗口计,官方约 80% 才压缩),旧会话已空闲(轮次边界)——现在接续到新会话?', autoContAgree: '同意接续', autoContReject: '拒绝', autoContTimeout: '{s} 秒后自动接续(视为挂机)', autoContRejected: '已跳过本次接续(下一个轮次边界再提醒)',
335
+ autoContTitle: '自动接续', autoContHint: '开启后:水位达到阈值即弹出确认卡——同意=立即接续,拒绝=本轮跳过(同一边界不再提示),30-40 秒无操作视为挂机自动接续;接续流程为先终止旧会话当前回合(未完成的回答会被打断)→ 让旧 Agent 刷新白板 PLAN 与交接账本并等它真正执行完 → 再建新会话(沿用工作区/模型/思考档位)并注入分层交接材料;触发后默认 30 分钟内不重复。', autoContOn: '自动接续:开', autoContOff: '自动接续:关', autoContThreshold: '阈值', autoContCountdown: '⏳ {s} 秒后自动接续…', autoContCancel: '取消', autoContConfirm: '本会话上下文已用 {p}%(按官方声明窗口计,官方约 80% 才压缩)。现在接续到新会话?点「同意接续」会先终止旧会话当前回合(未完成的回答会被打断),再让旧会话刷新白板与账本,然后建新会话。', autoContAgree: '同意接续', autoContReject: '拒绝', autoContTimeout: '{s} 秒后自动接续(视为挂机)', autoContRejected: '已跳过本次接续(下一个轮次边界再提醒)',
288
336
  continueCardTitle: '一键接续', continueCardHint: '写好交接账本→创建新会话→把交接材料作为首条消息预载,并自动切换到新会话继续任务(接续材料含白板节选+最新账本;当前工作区无账本时自动取全局最近一篇)。', continueBtn: '一键接续到新会话', continueBusy: '处理中…',
289
337
  fStyle: '反思风格', fStyleHint: '生活化 / 专业性 / 由内容决定。',
290
338
  fLocale: '界面语言', fLocaleHint: '默认跟随 DSH 系统语言;也可手动指定中文 / English。', followSystem: '跟随系统语言',
@@ -376,6 +424,37 @@ window.__ModuleLoader__.load({
376
424
  empty: '(empty)', back: '← Back', view: 'View',
377
425
  clickDateViewLog: 'Click a date to view the daily log (append-only):',
378
426
  userMemoryBlock: 'User memory (profile · cross-project):', userMemoryEmpty: '(empty)', userMemoryTruncated: '(content truncated for display)',
427
+ hubStageObserved: 'observed',
428
+ hubStageCandidate: 'candidate',
429
+ hubStageValidated: 'validated',
430
+ hubStageActive: 'active',
431
+ hubStageDeprecated: 'deprecated',
432
+ hubWhyTitle: 'Why it cannot be promoted yet: ',
433
+ hubWhyCanPromote: 'All conditions met — ready to promote.',
434
+ hubWhyDiversity: function (d, need) { return 'Needs ' + need + ' distinct sessions, currently ' + d + '.' },
435
+ hubWhySuccess: function (n, need) { return 'Needs ' + need + ' successes, currently ' + n + '.' },
436
+ hubWhyCorrectionRate: function (r, cap) { return 'Correction rate ' + r + ' exceeds cap ' + cap + '.' },
437
+ hubWhyHasCorrection: function (n) { return 'Has ' + n + ' correction(s) — a corrected flow must not be crystallized.' },
438
+ hubWhyNoCriteria: 'No success criteria — a flow without acceptance criteria is not a skill.',
439
+ hubWhyHighRisk: 'High risk — needs your confirmation before crystallizing.',
440
+ hubWhyObservationOnly: 'This is an observation, not a real flow — it never auto-promotes.',
441
+ hubEvSeen: 'seen', hubEvSuccess: 'success', hubEvSessions: 'sessions', hubEvReuse: 'reused', hubEvCorrection: 'corrected',
442
+ hubEvLine: function (ev) { return 'seen ' + (ev.seen || 0) + ' · success ' + (ev.success || 0) + ' · used in ' + (ev.sessions || 0) + ' session(s)' + ((ev.correction || 0) ? ' · corrected ' + ev.correction + 'x' : '') },
443
+ hubInjectPreview: 'This is what gets injected once promoted',
444
+ hubInjectCriteria: 'Acceptance criteria: ',
445
+ hubNoSteps: '(no usable steps — mechanically truncated, not a real flow)',
446
+ rulesTitle: 'Hard constraints (injected every turn)',
447
+ rulesIntro: 'These entries are sent to the model verbatim on every turn and outrank other content. They bypass retrieval and are never auto-retired, so outdated or AI-added ones must be edited by hand.',
448
+ rulesPath: 'Source file: ',
449
+ rulesEmpty: '(none)',
450
+ rulesSourceUser: 'yours',
451
+ rulesSourceAi: 'added by AI',
452
+ rulesEdit: 'Edit', rulesSave: 'Save', rulesCancel: 'Cancel', rulesDelete: 'Delete',
453
+ rulesConfirmDelete: 'Delete this constraint?\n\nThis cannot be undone — it will stop being injected next turn.',
454
+ rulesAddHint: 'Add a constraint:',
455
+ rulesAddPlaceholder: 'e.g. Do not modify production files without my confirmation.',
456
+ rulesAdd: 'Add',
457
+ rulesPreviewHint: 'Preview: how it will be injected next turn',
379
458
  appended: 'Appended', notesPathLabel: 'Project notes: ',
380
459
  notesPlaceholder: 'Content to append to project notes…(date heading added on save)',
381
460
  saving: 'Saving…', append: 'Append',
@@ -403,8 +482,9 @@ window.__ModuleLoader__.load({
403
482
  fProjectDir: 'Project memory dir', fProjectDirHint: 'Directory name relative to each workspace (default .dsh-memory).',
404
483
  fInject: 'Inject memory context', fInjectHint: 'Auto-inject <memory_system> block into every prompt.',
405
484
  fBudget: 'Injection budget (chars)', fBudgetHint: 'Total budget for the memory block; excess is truncated. Default 1600 (~400-600 tokens/turn); this background is injected every turn, so lower = fewer tokens, higher = more memory retained.',
406
- fNoteCap: 'Project note capacity (chars)', fNoteCapHint: 'Capacity cap for the project MEMORY.md, default 12000. When exceeded the framework auto-consolidates: older content is first folded into bullet points by the model, falling back to whole-record archiving (originals go to archive, never lost) — new memories are never blocked. This is NOT the injection budget above: this caps the file itself, the injection budget caps the digest added to each turn.',
407
- fUserCap: 'User memory capacity (chars)', fUserCapHint: 'Capacity cap for the user-level MEMORY.md (cross-project rules), default 12000. Same consolidation behaviour (fold into points first, whole-record archive as fallback).',
485
+ fExclude: 'Excluded sources (one per line)', fExcludeHint: 'These sources never enter injection again, so a bad memory cannot keep steering the result. Four forms: memory id (mem_xxx), whole layer (log/whiteboard/project/user/reflection), directory prefix (ending in / or \\), exact file path. Complements supersede: supersede covers "replaced by a newer version", this covers "this source is untrustworthy". Blocked counts are reported as a [降级] line, never silently.',
486
+ fNoteCap: 'Project note capacity (chars)', fNoteCapHint: 'Capacity cap for the project MEMORY.md, default 24000. When exceeded the framework auto-consolidates: older content is first folded into bullet points by the model, falling back to whole-record archiving (originals go to archive, never lost) — new memories are never blocked. This is NOT the injection budget above: this caps the file itself, the injection budget caps the digest added to each turn.',
487
+ fUserCap: 'User memory capacity (chars)', fUserCapHint: 'Capacity cap for the user-level MEMORY.md (cross-project rules), default 24000. Same consolidation behaviour (fold into points first, whole-record archive as fallback).',
408
488
  fSnapGap: 'Snapshot min gap (turns)', fSnapGapHint: 'After the dynamic memory snapshot changes, re-inject at least N turns later, so small per-turn log changes do not append a new snapshot every turn (history bloat). Default 5; 0 = try every turn (still change-gated).',
409
489
  fReinjectOnCompact: 'Re-inject snapshot immediately after compaction', fReinjectOnCompactHint: 'When the context is compacted/truncated (usually a contextVersion reset), the snapshot is cleared; on = force re-inject once so the memory background rebuilds. Default on.',
410
490
  fPromptCustom: 'Customize memory-injection prompt', fPromptCustomHint: 'Override any prompt layer (power feature). Placeholders: {date} {ws} {budget} {n}. One-click reset restores defaults.',
@@ -422,6 +502,8 @@ window.__ModuleLoader__.load({
422
502
  awayTitle: 'Welcome back', awayMsg: 'While you were away, I tidied up your logs. The memory panel is open — take a look at what happened.',
423
503
  sumTitle: 'Period summary',
424
504
  fReflect: 'Daily reflection', fReflectHint: 'When yesterday has logs, the agent presents the reflection at session start.',
505
+ fAutoContinue: 'Auto continue', fAutoContinueHint: 'Shows a confirm card once the water level passes the threshold; agreeing continues into a new session (workspace/model/reasoning effort are carried over). Off by default (still in testing); independent of the handoff whiteboard — turning the whiteboard off never disables water-level measurement or continue eligibility.',
506
+ handoffSwitchTitle: 'Whiteboard & auto continue switches', handoffSwitchHint: 'Both default to off (still in testing). These are the same config keys as Settings → Context — changing either applies globally.', handoffOn: 'Handoff whiteboard: on', handoffOff: 'Handoff whiteboard: off',
425
507
  fHandoff: 'Handoff whiteboard', fHandoffHint: 'PLAN.md snapshot + four-part handoff ledger: written by the model at milestones, injected first in the dynamic snapshot, live in the Whiteboard tab — context that survives windows.',
426
508
  fWaterWindow: 'Water-level window (tokens)', fWaterWindowHint: 'Session messages priced with the official meter (4 chars ≈ 1 token); past this, context is considered nearly full; 0 disables. Default 65536 — a conservative half of a 128K window.',
427
509
  fWaterAdvisory: 'Water-level handoff advisory', fWaterAdvisoryHint: 'Injects a handoff advisory past the threshold (write ledger / refresh plan / suggest a new window); silent in unattended mode.',
@@ -435,7 +517,7 @@ window.__ModuleLoader__.load({
435
517
  fMaintSchedule: 'Scheduled 30-day distill', fMaintScheduleHint: 'Once a day at the set time, distill & archive logs older than 30 days (same flow as memory_maintain); zero cost when nothing to archive.',
436
518
  fMaintScheduleTime: 'Distill time', fMaintScheduleTimeHint: 'HH:MM, default 10:00 (staggered from the consolidation time).',
437
519
  waterCardTitle: 'Context water level', waterAutoSrc: 'auto-detected', waterManualSrc: 'manual', waterOfficialSrc: 'route capacity', waterFallbackSrc: 'fallback default', waterNotMeasured: 'Not measured for this session yet (updates after the next turn)', waterMeterOfficial: 'official meter', waterMeterHeuristic: 'heuristic fallback', waterThresholdHint: 'Past {t} of the window, the handoff advisory is injected and the skeleton ledger is backfilled; metering prefers the official token-meter (same source as the chat context ring), falling back to the heuristic when unavailable; the window prefers the official route capacity (request/context contextWindow), then the active model in settings.yaml, or your manual override below; the trigger denominator is the official declared window (min with any provider-reported hard limit) — the reserved output budget is not deducted.',
438
- autoContTitle: 'Auto continue', autoContHint: 'When on: once the water level passes the threshold AND the old session is idle (the authoritative harness running bit flipped to false at a turn boundary, replacing the old "level unchanged across two polls"), a confirm card appears — Agree = continue now, Decline = skip this boundary (no re-prompt for it), no answer for 30-40s = unattended, continue automatically. Before continuing, the old agent refreshes PLAN + ledger; the new session keeps workspace/model/reasoning effort and receives layered handoff material; suppressed for 30 minutes after each run.', autoContOn: 'Auto continue: on', autoContOff: 'Auto continue: off', autoContThreshold: 'Threshold', autoContCountdown: '⏳ Auto-continue in {s}s…', autoContCancel: 'Cancel', autoContConfirm: 'Context at {p}% of the official declared window (official compaction starts ~80%) and the old session is idle (turn boundary) — continue into a new session now?', autoContAgree: 'Continue', autoContReject: 'Decline', autoContTimeout: 'auto-continue in {s}s (unattended)', autoContRejected: 'Skipped this handoff (will ask again at the next turn boundary)',
520
+ autoContTitle: 'Auto continue', autoContHint: 'When on: a confirm card appears once the water level passes the threshold — Agree = continue now, Decline = skip this boundary (no re-prompt for it), no answer for 30-40s = unattended, continue automatically. The handoff sequence is: stop the old turn first (an unfinished answer will be interrupted) → have the old agent refresh PLAN + ledger and wait until it has really run → then create the new session, which keeps workspace/model/reasoning effort and receives layered handoff material; suppressed for 30 minutes after each run.', autoContOn: 'Auto continue: on', autoContOff: 'Auto continue: off', autoContThreshold: 'Threshold', autoContCountdown: '⏳ Auto-continue in {s}s…', autoContCancel: 'Cancel', autoContConfirm: 'Context at {p}% of the official declared window (official compaction starts ~80%). Continue into a new session now? Agreeing stops the old turn first (an unfinished answer will be interrupted), then the old agent refreshes PLAN + ledger before the new session is created.', autoContAgree: 'Continue', autoContReject: 'Decline', autoContTimeout: 'auto-continue in {s}s (unattended)', autoContRejected: 'Skipped this handoff (will ask again at the next turn boundary)',
439
521
  continueCardTitle: 'One-click continue', continueCardHint: 'Writes the handoff ledger → creates a new session → injects the handoff material as the first message → switches to the new session automatically (material = PLAN excerpt + latest ledger; falls back to the most recent global ledger when this workspace has none).', continueBtn: 'Continue in a new session', continueBusy: 'Working…',
440
522
  fStyle: 'Reflection style', fStyleHint: 'Life-style / Professional / Auto.',
441
523
  fLocale: 'UI language', fLocaleHint: 'Follows the DSH system language by default; you can also pin Chinese / English.', followSystem: 'Follow system language',
@@ -537,7 +619,17 @@ window.__ModuleLoader__.load({
537
619
  snapshotCalendarTitle: '[日历与日程(未完成)]',
538
620
  snapshotWelcomeTitle: '[欢迎回来]',
539
621
  snapshotWelcomeBody: '用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来…',
540
- snapshotInscription: '[铭文 · 每轮提醒 {date}]',
622
+ // ★T5(2026-09-20 用户拍板)同步服务端 snapshotInscription:铭文自 v0.1.30 起为空壳,
623
+ // 现恢复「收尾自检三大方向 + 分类别丢」正文。**此常量必须与服务端 lib/index.js 逐字一致**
624
+ // (设置页「提示词层级」展示的就是这份镜像,漂移会让用户看到的与实际注入不符)。
625
+ snapshotInscription: '[铭文 · 每轮提醒 {date}]\n'
626
+ + '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
627
+ + '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
628
+ + '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\n · 本轮**改过 lib/ 下任何文件**(含测试/工具) ⇒ 必写 **memory_note(kind=handoff)** 四段式账本;\n · 白板"当前进度"与本轮结束时的**事实不符**(回归数字/已完成项/下一步) ⇒ 必做 **memory_note(kind=plan)** 重写白板;\n · 仅补充一条可复用结论 ⇒ 才用 **memory_note(kind=note, action=append)**。\n ⚠️ **kind=note 不等于白板/账本**——只写 note 却宣称"已更新白板与账本"是**失职**;write 直接写 docs/ 的 md 也**不算**插件记忆。\n'
629
+ + '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
630
+ + '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
631
+ + '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
632
+ + '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
541
633
  snapshotTail: '</memory_system>',
542
634
  }
543
635
  var FONT_SCALE_VALUES = { sm: '0.9', md: '1', lg: '1.15', xl: '1.3' }
@@ -567,14 +659,41 @@ window.__ModuleLoader__.load({
567
659
 
568
660
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
569
661
  var CHANGELOG = {
570
- '2.5.3': { zh: [
571
- '修复:provider 前缀式模型 id(如 deepseek/deepseek-v4.1-flash、z-ai/glm-5.3-flash、meituan/LongCat-2.0:free)的上下文窗口此前被静默丢弃——解析窗口的字符类不含斜杠,整条 id 行匹配不上,该路由的窗口退回兜底 131072;真实窗口 100 万的会话水位被放大 7.63 倍(12.8% 显示成 98%),未到阈值就误弹「接续到新会话」确认卡。现字符类含斜杠,前缀式 id 能取到真实窗口。',
572
- '修复:id 行存在但格式不被识别时(带引号/含空格等),紧随其后的 contextWindow 不再被错记到上一条模型头上——错配会给出偏小的窗口、让水位漏报,比读不到更危险。',
573
- '内部:群日报与群反馈跟踪的 CI 脚本同步(对使用无影响)。',
662
+ '3.0.1': { zh: [
663
+ '修复:宿主控制台被 `restoreLastAgent: candidate rejected` **无限刷屏**(每 15 秒一条,打开就停不下来),同时「最近会话」恢复失效——刷新仪式与自动接续的兜底路径都受影响。根因是归属判据过宽:代码把「有父会话」等同于「是子代理」并一律排除,但**你点过一次接续后产生的新会话其实也有父会话**,它是你的正常会话,却被当成子代理拒掉了;列表里只剩它一个时,每个心跳周期都白试一次。现在改为按 `origin`/`delegationDepth` 精确识别子代理,接续会话正常放行;另给诊断信息加了 5 分钟节流兜底。',
664
+ '顺带修好两处同样的误判:接续会话里的对话此前**不会自动沉淀**、工作区状态也**不刷新**——都因为同一条过宽判据。',
665
+ '修复:日历「当天」视图里 **23:59 截止的事项永远不显示**。时段轴原先写死 7:00–22:00,凡落在该区间外(或时间格式解析不出)的事项都被静默丢掉——你自己的课表里就有 7 条这样的截止项。现改为按当天真实事项推算时段范围,并新增「未定时」段落兜住解析不出时间的条目。',
666
+ '新增:日历当天视图支持前后翻天,并显示 ISO 周序号。',
667
+ '优化:白板卡片的标题不再是小节名。原先「目标」这一节名重复出现上百次,读起来没有信息量;现在标题取正文首行、小节名降为小标签,并自动剥掉 `<!-- ... -->` 注释与 `- type:xxx | ` 这类前缀。**后端与数据文件零改动**,只在展示层清洗。',
668
+ '说明:白板「节点图画布」本轮**暂不开放**(默认隐藏)。它的数据源依赖工作区解析,当前在你的机器上解析不稳定时会显示「暂无内容」;代码保留在插件内,待解析路径稳定后再放出。原「白板看板」泳道视图完全不受影响。',
669
+ ], en: [
670
+ 'Fix: the host console was flooded by `restoreLastAgent: candidate rejected` — one line every 15 seconds, nonstop — and recent-session recovery stopped working (refresh ceremony and the auto-continue fallback both depend on it). Root cause was an over-broad ownership test: "has a parent session" was treated as "is a sub-agent" and excluded. But a session created by **continuing** an older one also has a parent — it is your normal session, and it was being rejected as if it were a sub-agent. When it was the only session in the list, every heartbeat retried in vain. Ownership is now decided by `origin`/`delegationDepth`, so continuation sessions pass; diagnostics are also throttled to one line per 5 minutes as a backstop.',
671
+ 'Two more instances of the same misjudgement fixed along the way: conversations inside a continuation session were **never auto-consolidated**, and its workspace state **never refreshed** — both from the same over-broad test.',
672
+ 'Fix: items due at **23:59 never appeared in the calendar day view**. The hour axis was hardcoded to 07:00–22:00, so anything outside it (or with an unparseable time) was silently dropped — 7 of your own deadlines were affected. The range is now derived from the actual events of the day, with a new "untimed" section for entries whose time cannot be parsed.',
673
+ 'New: prev/next day navigation in the calendar day view, plus the ISO week number.',
674
+ 'Improved: whiteboard card titles are no longer section names. The heading "目标" used to repeat over a hundred times — now the title is the first line of the body, the section name becomes a small kicker, and `<!-- ... -->` comments and `- type:xxx | ` prefixes are stripped. Backend and data files are untouched; cleaning happens in the render layer only.',
675
+ 'Note: the whiteboard node-graph canvas is **not shipped in this release** (hidden by default). It depends on workspace resolution, which is unstable on your machine and shows an empty canvas; the code stays in the plugin and will be enabled once resolution is reliable. The existing swimlane whiteboard is unaffected.',
676
+ ] },
677
+ '3.0.0': { zh: [
678
+ '★ 大版本:底层重建收官。检索、注入、容量、并发四条底层全部重做;白板与看板从「实验特性」变成出厂形态。',
679
+ '★ 白板默认开启(原来默认关)。交接白板 + 四段式交接账本是这一版的招牌能力,出厂关着等于新用户看不到它。想彻底关掉:设置 → 自动化 → 交接白板。',
680
+ '★ 白板默认「新版看板」:结构化索引(条目/标签/线索/版本链)+ 列式泳道视图 + 两个遍历工具(按标签展开 memory_expand / 按 id 回溯 memory_trace)。旧的文字白板完整保留,设置页一键切回(「旧版白板」)。',
681
+ '说明:自动接续**仍然默认关闭**——水位判据还没在你的机器上实测到位,这一版特意不动它,等验证满意再考虑。',
682
+ '修复:语义唤回长期「查不到东西」的真因。语义索引因一次没收尾的同步被永久占住(重启也只能暂时缓解),此后每轮都退化成「关键词 + 常驻目录」。现已修好:索引能建起来,唤回恢复语义检索。',
683
+ '修复:升级弹窗原先只弹 2.1.0 的旧说明(版本号写死),所以你一直看不到新版更新日志。现在会自动展示当前版本的说明。',
684
+ '修复:同时开两个会话时「谁也没法注入」、点进不同工作区时语义模型跟着走——4 处状态被两会话互相覆盖,现已按会话/工作区分开。',
685
+ '新增:自动接续的续航材料更完整;上下文将满时给出的白板/账本提示更稳。',
686
+ '老用户不受影响:你自己设过的开关与阈值一律沿用,只有从没设过的那几项会吃新默认。',
574
687
  ], en: [
575
- 'Fix: context windows for provider-prefixed model ids (deepseek/deepseek-v4.1-flash, z-ai/glm-5.3-flash, meituan/LongCat-2.0:free) were silently dropped — the id character class had no "/", so the whole id line failed to match and that route fell back to 131072; on a session whose real window is 1,000,000 the water level was inflated 7.63x (12.8% shown as 98%), firing the "continue in a new session" prompt below the threshold. The character class now includes "/".',
576
- 'Fix: when an id line exists but still cannot be parsed (quoted, spaces, etc.), the following contextWindow is no longer attributed to the previous model — that misattribution yields a too-small window and under-reports the water level, which is worse than not reading it at all.',
577
- 'Internal: CI scripts for the daily group digest and group feedback tracking synced (no user-facing effect).',
688
+ '★ Major release: the ground-up rebuild is complete. Retrieval, injection, capacity and concurrency were all rebuilt; the whiteboard and kanban are now shipping defaults rather than experiments.',
689
+ '★ Whiteboard is now ON by default (it used to be off). The handoff whiteboard plus four-part ledgers are this release\'s headline feature — off by default meant new users never saw them. To disable entirely: Settings → Automation → Handoff whiteboard.',
690
+ '★ Whiteboard now defaults to the new kanban: a structured index (entries/tags/cues/version chain), a column-swimlane view, and two traversal tools (expand by tag, trace by id). The old text whiteboard is fully retained — switch back with one click in Settings.',
691
+ 'Note: auto-continue remains OFF by default — the water-level criteria have not yet been validated on your machine, so this release deliberately leaves it alone.',
692
+ 'Fix: the real cause of semantic recall coming up empty for weeks. A sync that never finished had permanently occupied the index slot (restarting only helped temporarily), so every turn degraded to keyword + resident catalog. Fixed: the index now builds, and recall is semantic again.',
693
+ 'Fix: the upgrade dialog only ever showed the old 2.1.0 notes (the version was hardcoded), so you never saw the current release notes. It now shows the version you actually upgraded to.',
694
+ 'Fix: with two sessions open, neither could inject, and the semantic model followed you between workspaces — four pieces of state were overwriting each other. Now scoped per session/workspace.',
695
+ 'New: more complete carry-over material for auto-continue; steadier whiteboard/ledger prompting as context fills up.',
696
+ 'Existing users are unaffected: any switch or threshold you set yourself is kept — only never-set keys pick up the new defaults.',
578
697
  ] },
579
698
  '2.5.2': { zh: [
580
699
  '修复:开启「无人值守模式」的用户切换工作区失灵(面板恒显示启动目录、日志 0)——防漂移锁此前全局钉死首个工作区,现改为按会话锁定,只防本会话内的目录漂移;未开启该模式的用户行为不变。',
@@ -904,11 +1023,82 @@ window.__ModuleLoader__.load({
904
1023
  else dialogQueue.push(d)
905
1024
  notifyDialog()
906
1025
  }
1026
+ // issue#40:宿主配置控制自动播放;localStorage 只记录**用户动作**,不得反过来当开关。
1027
+ var tourNavigationEpoch = 0
1028
+ var welcomeTourConfig = null
1029
+ function decodeWelcomeConfigPre(response) {
1030
+ if (!response || typeof response !== 'object' || Array.isArray(response)) return null
1031
+ var cfg = response.config
1032
+ if (!cfg && Object.prototype.hasOwnProperty.call(response, 'welcomeTourEnabled')) cfg = response
1033
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return null
1034
+ if (cfg.welcomeTourEnabled !== undefined && typeof cfg.welcomeTourEnabled !== 'boolean') return null
1035
+ return cfg
1036
+ }
1037
+ function welcomeAutoAllowedPre(cfg) {
1038
+ if (!cfg || cfg.welcomeTourEnabled === false) return false
1039
+ try { if (localStorage.getItem('dsh-auto-memory.tourDismissed') === '1') return false } catch (_) { return false }
1040
+ return true
1041
+ }
1042
+ function dismissWelcomeTourPre(reason) {
1043
+ tourNavigationEpoch++
1044
+ if (reason === 'user') {
1045
+ try { localStorage.setItem('dsh-auto-memory.tourDismissed', '1') } catch (_) {}
1046
+ }
1047
+ function target(d) { return d && d.kind === 'welcomeTour' && (reason !== 'config' || !d.manual) }
1048
+ dialogQueue = dialogQueue.filter(function (d) { return !target(d) })
1049
+ if (target(dialogState)) dialogState = dialogQueue.shift() || null
1050
+ notifyDialog()
1051
+ }
1052
+ function rememberWelcomeConfigPre(response) {
1053
+ welcomeTourConfig = decodeWelcomeConfigPre(response)
1054
+ if (welcomeTourConfig && welcomeTourConfig.welcomeTourEnabled === false) dismissWelcomeTourPre('config')
1055
+ return welcomeTourConfig
1056
+ }
1057
+ // 焦点陷阱:向导内的 Tab 不得逃到背景页面;Esc 关闭;关闭后焦点归还触发元素。
1058
+ function installTourFocusPre(root, close, doc) {
1059
+ if (!root || !doc) return function () {}
1060
+ var previous = doc.activeElement
1061
+ var selector = 'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
1062
+ function focusables() {
1063
+ return Array.prototype.filter.call(root.querySelectorAll(selector), function (el) { return el.getClientRects().length > 0 && el.getAttribute('aria-hidden') !== 'true' })
1064
+ }
1065
+ function onKey(e) {
1066
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
1067
+ if (e.key !== 'Tab') return
1068
+ var items = focusables(), first = items[0], last = items[items.length - 1]
1069
+ if (!items.length) { e.preventDefault(); root.focus(); return }
1070
+ if (e.shiftKey && (doc.activeElement === first || !root.contains(doc.activeElement))) { e.preventDefault(); last.focus() }
1071
+ else if (!e.shiftKey && (doc.activeElement === last || !root.contains(doc.activeElement))) { e.preventDefault(); first.focus() }
1072
+ }
1073
+ var keyTarget = typeof doc.addEventListener === 'function' ? doc : root
1074
+ keyTarget.addEventListener('keydown', onKey, true)
1075
+ var initial = focusables()[0] || root
1076
+ initial.focus()
1077
+ return function () {
1078
+ keyTarget.removeEventListener('keydown', onKey, true)
1079
+ if (previous && previous.isConnected && typeof previous.focus === 'function' &&
1080
+ (root.contains(doc.activeElement) || doc.activeElement === doc.body)) previous.focus()
1081
+ }
1082
+ }
1083
+ function openManualWelcomeTourPre() {
1084
+ tourNavigationEpoch++
1085
+ openDialog({ kind: 'welcomeTour', manual: true })
1086
+ }
1087
+ function loadWelcomeConfigPre(timeoutMs) {
1088
+ return new Promise(function (resolve) {
1089
+ var finished = false
1090
+ var timer = setTimeout(function () { if (!finished) { finished = true; resolve(null) } }, timeoutMs || 5000)
1091
+ apiGet(API.config).then(function (d) {
1092
+ if (finished) return
1093
+ finished = true; clearTimeout(timer); resolve(rememberWelcomeConfigPre(d))
1094
+ }, function () { if (!finished) { finished = true; clearTimeout(timer); resolve(null) } })
1095
+ })
1096
+ }
907
1097
  function closeDialog() { dialogState = dialogQueue.shift() || null; notifyDialog() }
908
1098
  function onDialog(fn) { dialogListeners.add(fn); return function () { dialogListeners.delete(fn) } }
909
1099
  // 调试/重看入口:控制台 window['dsh-auto-memory.openWelcomeTour']() 随时重开首启向导
910
1100
  try {
911
- window['dsh-auto-memory.openWelcomeTour'] = function () { openDialog({ kind: 'welcomeTour' }) }
1101
+ window['dsh-auto-memory.openWelcomeTour'] = function () { openManualWelcomeTourPre() }
912
1102
  } catch (eOpenTour) {}
913
1103
  function cmpVersion(a, b) {
914
1104
  var pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number)
@@ -948,6 +1138,9 @@ window.__ModuleLoader__.load({
948
1138
  reflect: ROUTE_PREFIX + '/reflect',
949
1139
  reflectAuto: ROUTE_PREFIX + '/reflect-auto',
950
1140
  note: ROUTE_PREFIX + '/note',
1141
+ // ★R7:用户级硬性约束的条目级读写
1142
+ rulesList: ROUTE_PREFIX + '/rules',
1143
+ rulesApply: ROUTE_PREFIX + '/rules/apply',
951
1144
  external: ROUTE_PREFIX + '/external',
952
1145
  externalView: ROUTE_PREFIX + '/external-view',
953
1146
  externalRemove: ROUTE_PREFIX + '/external-remove',
@@ -967,6 +1160,7 @@ window.__ModuleLoader__.load({
967
1160
  pyCancel: ROUTE_PREFIX + '/python-setup/cancel',
968
1161
  pyStatus: ROUTE_PREFIX + '/python-setup/status',
969
1162
  handoffState: ROUTE_PREFIX + '/handoff-state',
1163
+ kanbanBoard: ROUTE_PREFIX + '/kanban-board',
970
1164
  handoffContinue: ROUTE_PREFIX + '/handoff-continue',
971
1165
  handoffPermission: ROUTE_PREFIX + '/handoff-permission',
972
1166
  autoContState: ROUTE_PREFIX + '/auto-continue-state',
@@ -1003,11 +1197,30 @@ window.__ModuleLoader__.load({
1003
1197
  */
1004
1198
  function saveConfigPatch(patch, hooks) {
1005
1199
  var h = hooks || {}
1006
- var p = apiPost(API.config, patch)
1200
+ // issue#40:保存后立刻用**回传的真实配置**刷新向导闸,而不是等下次启动重新拉取。
1201
+ // 关掉开关时顺手撤下正在显示的向导(否则"点了关闭但窗口还开着"——界面无即时回显)。
1202
+ var p = apiPost(API.config, patch).then(function (d) {
1203
+ rememberWelcomeConfigPre(d)
1204
+ if (patch && typeof patch.welcomeTourEnabled === 'boolean') {
1205
+ welcomeTourConfig = Object.assign({}, welcomeTourConfig || {}, { welcomeTourEnabled: patch.welcomeTourEnabled })
1206
+ if (patch.welcomeTourEnabled === false) dismissWelcomeTourPre('config')
1207
+ }
1208
+ return d
1209
+ })
1007
1210
  if (h.onSaved || h.onError) p.then(function (d) { if (h.onSaved) h.onSaved(d) }, function (e) { if (h.onError) h.onError(e) })
1008
1211
  else p.catch(function () {})
1009
1212
  return p
1010
1213
  }
1214
+ /**
1215
+ * 读配置的唯一解包出口(2026-09-14 实测止血):宿主 GET /config **恒返回外壳** `{ config, path }`
1216
+ * (见 lib/index.js:8461),配置键在 `d.config` 上。
1217
+ * 直读外壳的旧写法(`c.autoContinueEnabled`)恒得 undefined ⇒ `undefined === false` 恒假 ⇒ `!(…)` 恒真
1218
+ * ⇒ 开关显示**恒为「开」**,与真实配置无关;更糟的是点击算的是 `!autoCfg.enabled` = `!true` = **false**
1219
+ * ⇒ 该按钮**只能关、永远开不起来**(用户报障原话「我点开关,它并没有真正开关」)。
1220
+ * 纪律:任何 `apiGet(API.config)` 的返回值都必须经这层解包,禁止直读外壳上的配置键。
1221
+ * 回归守卫:tests/smoke/smoke-test-switch-decouple-pre.mjs D5。
1222
+ */
1223
+ function configOf(d) { return (d && d.config) || {} }
1011
1224
  /**
1012
1225
  * 语义引擎状态的统一刷新(2026-09-10 第 0 步止血):此前同一段 fetch + 状态写回在界面里逐字重复 10 处,
1013
1226
  * 响应形状或失败兜底要改时得挨个改。apply 收合并后的状态对象(已带 loaded:true),onError 可省。
@@ -1026,7 +1239,7 @@ window.__ModuleLoader__.load({
1026
1239
  ' max-width: calc(100vw - 32px); max-height: calc(100vh - 32px);',
1027
1240
  ' display: flex; flex-direction: column; overflow: hidden; z-index: 3000; pointer-events: auto;',
1028
1241
  ' border-radius: 16px; font: 13px/1.55 system-ui, "Segoe UI", sans-serif;',
1029
- ' background: color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(255,255,255,.86)) 58%, transparent);',
1242
+ ' background: color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(255,255,255,.86)) 58%, transparent);',
1030
1243
  ' -webkit-backdrop-filter: blur(28px) saturate(1.55); backdrop-filter: blur(28px) saturate(1.55);',
1031
1244
  ' border: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.35)) 65%, transparent);',
1032
1245
  ' box-shadow: 0 24px 64px rgba(0,0,0,.22), 0 4px 16px rgba(0,0,0,.10), inset 0 1px 0 rgba(255,255,255,.22);',
@@ -1074,7 +1287,7 @@ window.__ModuleLoader__.load({
1074
1287
  '[data-dam-settings-group] h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2328); font-size: calc(14px * var(--dam-scale)); }',
1075
1288
  '[data-dam-settings-group] p { margin: 4px 0 14px; color: var(--dsw-alias-label-secondary, #68717d); font-size: calc(11.5px * var(--dam-scale)); line-height: 1.5; }',
1076
1289
  '[data-dam-settings-row] { padding: 10px 0; }',
1077
- '[data-dam-savebar] { position: sticky; bottom: 0; z-index: 3; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; margin: 6px -16px -16px; background: color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(255,255,255,.92)) 74%, transparent); backdrop-filter: blur(16px) saturate(1.4); -webkit-backdrop-filter: blur(16px) saturate(1.4); border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, #c5cbd3) 38%, transparent); border-radius: 0 0 14px 14px; transition: background .25s ease; }',
1290
+ '[data-dam-savebar] { position: sticky; bottom: 0; z-index: 3; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; margin: 6px -16px -16px; background: color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(255,255,255,.92)) 74%, transparent); backdrop-filter: blur(16px) saturate(1.4); -webkit-backdrop-filter: blur(16px) saturate(1.4); border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l1, #c5cbd3) 38%, transparent); border-radius: 0 0 14px 14px; transition: background .25s ease; }',
1078
1291
  '[data-dam-savebar] button { opacity: .5; transition: opacity .2s ease, background .2s ease, color .2s ease; }',
1079
1292
  '[data-dam-savebar] button[data-dirty="true"] { opacity: 1; background: var(--dam-accent, #1d4ed8); color: #fff; font-weight: 650; box-shadow: 0 3px 12px color-mix(in srgb, var(--dam-accent, #1d4ed8) 35%, transparent); }',
1080
1293
  '[data-dam-settings-row] > [data-dam-row] { margin-bottom: 3px; }',
@@ -1127,13 +1340,13 @@ window.__ModuleLoader__.load({
1127
1340
  '[data-dam-spinner] { width: 18px; height: 18px; border: 2px solid color-mix(in srgb, var(--dam-accent, #4f7cff) 22%, transparent); border-top-color: var(--dam-accent, #4f7cff); border-radius: 50%; animation: dam-spin .8s linear infinite; }',
1128
1341
  '@keyframes dam-spin { to { transform: rotate(360deg); } }',
1129
1342
  '@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {',
1130
- ' [data-dam-panel] { background: var(--dsw-alias-bg-overlay, #ffffff); } }',
1343
+ ' [data-dam-panel] { background: var(--dsw-alias-bg-layer-2, #ffffff); } }',
1131
1344
  // ── 首启引导向导(Win11 OOBE × macOS 欢迎 × Liquid Glass) ──
1132
1345
  '[data-dam-tour-backdrop] { position: fixed; inset: 0; z-index: 2147482900; display: flex; align-items: center; justify-content: center;',
1133
1346
  ' background: radial-gradient(ellipse at 50% 42%, rgba(20,30,60,.30), rgba(6,10,22,.44)); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); animation: dam-tour-fade .28s ease both; }',
1134
1347
  '@keyframes dam-tour-fade { from { opacity: 0 } to { opacity: 1 } }',
1135
- '[data-dam-tour] { position: relative; width: min(620px, calc(100vw - 48px)); border-radius: 22px; overflow: hidden; padding-bottom: 4px;',
1136
- ' background: color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(30,34,46,.9)) 60%, transparent);',
1348
+ '[data-dam-tour] { position: relative; width: min(620px, calc(100vw - 48px)); border-radius: 22px; max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); overflow-y: auto; overflow-x: hidden; touch-action: pan-y; box-sizing: border-box; padding-bottom: 4px;',
1349
+ ' background: color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(30,34,46,.9)) 60%, transparent);',
1137
1350
  ' backdrop-filter: blur(30px) saturate(1.6); -webkit-backdrop-filter: blur(30px) saturate(1.6);',
1138
1351
  ' border: 1px solid rgba(255,255,255,.5);',
1139
1352
  ' box-shadow: 0 32px 90px rgba(8,14,38,.5), 0 6px 24px rgba(8,14,38,.28), inset 0 1px 0 rgba(255,255,255,.5), inset 0 -1px 0 rgba(255,255,255,.14);',
@@ -1352,6 +1565,1005 @@ window.__ModuleLoader__.load({
1352
1565
  h('div', { className: 'dam-content' }, props.children))
1353
1566
  }
1354
1567
 
1568
+ // ─────────────────────────────────────────────────────────────────────────
1569
+ // 白板看板(2026-09-16「兼并 dsh-graph」) —— 列式泳道可视化。
1570
+ //
1571
+ // 立场: 不挂 dsh-graph 插件(那会往 profile 里加第二个 bundle、第二份存储、第二套工具命名空间),
1572
+ // 而是把它的**可视化形态**长在 auto-memory 自己身上: 数据源是自家的 handoff/index.json,
1573
+ // 渲染在自家的面板里, 由 boardMode 一档切换(legacy 文字白板 / graph 列式看板)。
1574
+ // ⇒ 一份数据、一个插件、一个开关。
1575
+ //
1576
+ // 视觉: 与面板既有液态玻璃令牌一致(--dam-* / --dsw-alias-*), 不引第三方 CSS。
1577
+ // 纯展示组件: 只读 data, 不发起任何写操作(看板改卡留待后续; 先保证「看得见」)。
1578
+ // ─────────────────────────────────────────────────────────────────────────
1579
+ var KANBAN_TONES = {
1580
+ ok: { bg: 'color-mix(in srgb, #3fa96a 18%, transparent)', fg: '#2f7d4f' },
1581
+ warn: { bg: 'color-mix(in srgb, #d4a94f 20%, transparent)', fg: '#8a6a1f' },
1582
+ unknown: { bg: 'color-mix(in srgb, currentColor 10%, transparent)', fg: 'inherit' },
1583
+ }
1584
+
1585
+ // ─────────────────────────────────────────────────────────────────────────
1586
+ // 看板 v2(2026-09-16 「大刀阔斧」批) —— 缩放 / 布局 / 交互全部补齐。
1587
+ //
1588
+ // v1 的三个致命体验问题(用户实测反馈):
1589
+ // ① 大小不可调: 只有面板全局 4 档 --dam-scale, 列宽 210px 写死不随字号变;
1590
+ // ② 看不见: 6 列 × 210px = 1260px 塞进 440px 面板, 只能看到 2 列且无滚动提示;
1591
+ // ③ 看不全: 预览 maxHeight 3.6em 硬截断, 无法展开。
1592
+ // ⇒ v2: 看板自带字号 ×N 与列宽 ×N(独立于 --dam-scale, localStorage 记忆) + 纵向堆叠默认 +
1593
+ // 泳道可折叠 + 卡片可展开看全文 + 泳道内搜索 + 「最近更新」聚合视图 + 空泳道收起。
1594
+ // ─────────────────────────────────────────────────────────────────────────
1595
+ var KANBAN_ZOOM_KEY = 'dam-kanban-zoom'
1596
+ var KANBAN_COL_KEY = 'dam-kanban-cols'
1597
+ var KANBAN_LAYOUT_KEY = 'dam-kanban-layout'
1598
+
1599
+ function readLsNum(key, def) {
1600
+ try { var v = parseFloat(localStorage.getItem(key)); return isFinite(v) && v > 0 ? v : def } catch (e) { return def }
1601
+ }
1602
+ function writeLs(key, val) { try { localStorage.setItem(key, String(val)) } catch (e) {} }
1603
+
1604
+ // ─────────────────────────────────────────────────────────────────────────
1605
+ // 白板内容清洗层(2026-09-20 重构批)
1606
+ //
1607
+ // 为什么需要: 卡片数据来自 Markdown 的 `## ` 小节直通(wb-sidecar.js:252→:666),
1608
+ // 全链路无摘要/清洗/标题正文分离 ⇒ 实测 868 张卡只有 195 个不同标题
1609
+ // (「目标」重复 109 次), 且正文首行常是未剥离的 `<!-- type:goal -->`。
1610
+ // title 与 body 因此互为镜像 —— 两个字段的信息量只有 1 个。
1611
+ //
1612
+ // 立场: 纯前端派生, 不改后端契约(wb-contract.js 的写入门与 sidecar 结构零改动)。
1613
+ // 清洗是**渲染层**职责: 磁盘上的字节保持原样, 只在显示时剥掉元数据。
1614
+ // ─────────────────────────────────────────────────────────────────────────
1615
+ /** 剥 HTML 注释(锚点行 `<!-- memory:mem_xxx -->` 与 tag 行 `<!-- type:goal -->`)。 */
1616
+ function wbStripComment(s) { return String(s || '').replace(/<!--[\s\S]*?-->/g, '') }
1617
+ /** 剥行首的 tag 前缀(`- type:state | ` / `type:goal` 等)与列表符。 */
1618
+ function wbStripPrefix(s) {
1619
+ return String(s || '')
1620
+ .replace(/^\s*[-*+]\s+/, '')
1621
+ .replace(/^\s*(?:tag|type|topic)\s*[::]\s*[\w\u4e00-\u9fff-]+\s*[||]?\s*/i, '')
1622
+ .replace(/^\s*[||]\s*/, '')
1623
+ }
1624
+ /** 剥 Markdown 行内标记(粗体/斜体/行内代码/链接), 只留可读文字。 */
1625
+ function wbStripMarkup(s) {
1626
+ return String(s || '')
1627
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
1628
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2')
1629
+ .replace(/`([^`]+)`/g, '$1')
1630
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
1631
+ .replace(/\s+/g, ' ')
1632
+ .trim()
1633
+ }
1634
+ /** 一条原文 → 可读文本(剥注释 → 逐行剥前缀 → 剥标记), 丢弃空行。 */
1635
+ function wbCleanLines(s) {
1636
+ var lines = wbStripComment(s).split(/\r?\n/)
1637
+ var out = []
1638
+ for (var i = 0; i < lines.length; i++) {
1639
+ var t = wbStripMarkup(wbStripPrefix(lines[i]))
1640
+ if (t) out.push(t)
1641
+ }
1642
+ return out
1643
+ }
1644
+ /** 标题清洗: 剥注释与前缀后取首行, 过长截断。空则回落到小节名。 */
1645
+ function wbCleanTitle(raw) {
1646
+ var ls = wbCleanLines(raw)
1647
+ var t = ls.length ? ls[0] : ''
1648
+ if (!t) return ''
1649
+ return t.length > 60 ? t.slice(0, 57) + '…' : t
1650
+ }
1651
+ /**
1652
+ * 卡片 → { title, body, meta }
1653
+ * 标题优先级: 清洗后的正文首行 > card.title(小节名) > 来源文件名
1654
+ * 正文 = 剩余行拼接(已清洗), 与标题不重复 —— 这正是「标题+正文分离」。
1655
+ */
1656
+ function wbShape(c) {
1657
+ var rawFull = c.full || c.preview || ''
1658
+ var ls = wbCleanLines(rawFull)
1659
+ var head = ls.length ? ls[0] : ''
1660
+ var title = head ? (head.length > 60 ? head.slice(0, 57) + '…' : head) : (c.title || c.short || '')
1661
+ // 副标题: 小节名(短小、可当分类标签), 若与标题相同则省略
1662
+ var kicker = c.title && c.title !== title ? c.title : ''
1663
+ var rest = ls.slice(1)
1664
+ // 小节名常重复出现在正文首行(如「任务状态」), 再剥一次
1665
+ if (kicker && rest.length && rest[0] === kicker) rest = rest.slice(1)
1666
+ var body = rest.join(' ')
1667
+ var isDup = body.indexOf(title) === 0 && body.length <= title.length + 2
1668
+ if (isDup) body = ''
1669
+ return {
1670
+ title: title,
1671
+ kicker: kicker,
1672
+ body: body,
1673
+ docTitle: c.docTitle || '',
1674
+ source: c.source || '',
1675
+ section: c.section || '',
1676
+ tags: c.tags || [],
1677
+ sectionTags: c.sectionTags || [],
1678
+ anchored: !!c.anchored,
1679
+ id: c.id,
1680
+ ts: c.ts,
1681
+ lineStart: c.lineStart,
1682
+ kind: c.kind || '',
1683
+ mtime: c.mtime,
1684
+ }
1685
+ }
1686
+ /** 泳道 key → 色调(图上按类别着色, 与面板泳道语义一致)。 */
1687
+ function wbLaneTone(key) {
1688
+ var k = String(key || '')
1689
+ if (k === 'goal') return '#4d6bfe'
1690
+ if (k === 'state' || k === 'doing') return '#3fa96a'
1691
+ if (k === 'dead' || k === 'deadend') return '#c2603f'
1692
+ if (k === 'progress' || k === 'next') return '#d4a94f'
1693
+ if (k === 'archive') return '#8a7fbf'
1694
+ return 'rgba(128,128,128,.75)'
1695
+ }
1696
+
1697
+ /** 单张卡片: 标题 + 判据徽章 + 预览/全文 + 小节徽章 + 标签 + 来源。可点击展开。 */
1698
+ function KanbanCard(props) {
1699
+ var c = props.card || {}
1700
+ var zoom = Number(props.zoom) || 1
1701
+ var expanded = !!props.expanded
1702
+ var onToggle = props.onToggle
1703
+ var tone = KANBAN_TONES[(c.badge && c.badge.tone) || 'unknown'] || KANBAN_TONES.unknown
1704
+ var body = expanded ? (c.full || c.preview || '') : (c.preview || '')
1705
+ var canExpand = (c.full || '').length > (c.preview || '').length
1706
+ return h('div', {
1707
+ 'data-dam-kcard': '',
1708
+ title: c.source + (c.section ? ' §' + c.section : '') + (c.anchored ? ' · 有锚点' : ''),
1709
+ style: {
1710
+ border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.22)) 70%, transparent)',
1711
+ borderRadius: '8px', padding: '7px 9px', marginBottom: '7px',
1712
+ background: 'color-mix(in srgb, currentColor 3%, transparent)',
1713
+ },
1714
+ }, [
1715
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: '6px' } }, [
1716
+ h('div', { style: { flex: '1 1 auto', fontWeight: 600, fontSize: (12.5 * zoom) + 'px', lineHeight: '1.35', wordBreak: 'break-word' } }, c.title || c.short),
1717
+ h('span', {
1718
+ style: { flex: '0 0 auto', fontSize: (10 * zoom) + 'px', padding: '1px 5px', borderRadius: '4px', background: tone.bg, color: tone.fg, whiteSpace: 'nowrap' },
1719
+ }, (c.badge && c.badge.text) || ''),
1720
+ ]),
1721
+ // 文档来源(小节卡的关键上下文: 同名的「目标」小节来自哪篇账本)
1722
+ c.docTitle ? h('div', { 'data-dam-hint': '', style: { marginTop: '2px', fontSize: (10 * zoom) + 'px', opacity: .62, wordBreak: 'break-word' } }, c.docTitle) : null,
1723
+ body ? h('div', {
1724
+ 'data-dam-hint': '',
1725
+ style: {
1726
+ marginTop: '4px', lineHeight: '1.45', fontSize: (11.5 * zoom) + 'px', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
1727
+ maxHeight: expanded ? 'none' : (7.2 * zoom) + 'em', overflow: 'hidden',
1728
+ },
1729
+ }, body) : null,
1730
+ (canExpand || (c.full || '').length > 300) ? h('button', {
1731
+ 'data-dam-btn': '', onClick: function (e) { e.stopPropagation(); if (onToggle) onToggle(c.id) },
1732
+ style: { marginTop: '3px', padding: '1px 6px', fontSize: (10 * zoom) + 'px', opacity: .7 },
1733
+ }, expanded ? (locale === 'zh' ? '收起' : 'collapse') : (locale === 'zh' ? '展开全文' : 'expand')) : null,
1734
+ (c.tags && c.tags.length) || (c.sectionTags && c.sectionTags.length) ? h('div', { style: { marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '4px' } },
1735
+ (c.tags || []).map(function (tg) {
1736
+ return h('span', { key: 't' + tg, 'data-dam-hint': '', style: { fontSize: (10 * zoom) + 'px', padding: '0 4px', borderRadius: '3px', background: 'color-mix(in srgb, currentColor 8%, transparent)' } }, tg)
1737
+ }).concat((c.sectionTags || []).map(function (tg) {
1738
+ return h('span', { key: 's' + tg, 'data-dam-hint': '', style: { fontSize: (10 * zoom) + 'px', padding: '0 4px', borderRadius: '3px', background: 'color-mix(in srgb, #4f7cff 14%, transparent)', opacity: .85 } }, '§' + tg)
1739
+ }))) : null,
1740
+ h('div', { 'data-dam-hint': '', style: { marginTop: '4px', fontSize: (10 * zoom) + 'px' } },
1741
+ '#' + (c.short || '') + (c.bullets ? ' · ' + c.bullets + (locale === 'zh' ? ' 条' : ' items') : '') + (c.versionCount ? ' · v' + c.versionCount : '')),
1742
+ ])
1743
+ }
1744
+
1745
+ /** 看板主体: 布局可切(纵向堆叠/横向泳道) + 缩放 + 折叠 + 搜索 + 聚合视图。 */
1746
+ function KanbanBoard(props) {
1747
+ var kb = props.data || {}
1748
+ var lanes = kb.lanes || []
1749
+ var stats = kb.stats || {}
1750
+ var total = stats.total || 0
1751
+ var zoomPair = useState(function () { return readLsNum(KANBAN_ZOOM_KEY, 1) })
1752
+ var zoom = zoomPair[0], setZoom = zoomPair[1]
1753
+ var colPair = useState(function () { return readLsNum(KANBAN_COL_KEY, 280) })
1754
+ var colW = colPair[0], setColW = colPair[1]
1755
+ var layoutPair = useState(function () {
1756
+ try { return localStorage.getItem(KANBAN_LAYOUT_KEY) || 'stack' } catch (e) { return 'stack' }
1757
+ })
1758
+ var layout = layoutPair[0], setLayout = layoutPair[1]
1759
+ var foldedPair = useState({})
1760
+ var folded = foldedPair[0], setFolded = foldedPair[1]
1761
+ var expPair = useState({})
1762
+ var expanded = expPair[0], setExpanded = expPair[1]
1763
+ var viewPair = useState('lanes') // lanes | recent
1764
+ var view = viewPair[0], setView = viewPair[1]
1765
+ var qPair = useState('')
1766
+ var q = qPair[0], setQ = qPair[1]
1767
+
1768
+ if (!lanes.length || !total) {
1769
+ return h('div', { 'data-dam-hint': '' }, locale === 'zh'
1770
+ ? '新版看板已启用,但索引里还没有条目——写一次白板或账本(memory_note)后这里会出现泳道卡片。'
1771
+ : 'Graph board is on, but the index has no entries yet — write a plan or ledger (memory_note) and cards will appear.')
1772
+ }
1773
+ var zh = locale === 'zh'
1774
+ var setZoomBoth = function (d) { var v = Math.min(2.2, Math.max(0.7, Math.round((zoom + d) * 100) / 100)); setZoom(v); writeLs(KANBAN_ZOOM_KEY, v) }
1775
+ var setColBoth = function (d) { var v = Math.min(720, Math.max(180, colW + d)); setColW(v); writeLs(KANBAN_COL_KEY, v) }
1776
+ var toggleLayout = function () { var v = layout === 'stack' ? 'lane' : 'stack'; setLayout(v); writeLs(KANBAN_LAYOUT_KEY, v) }
1777
+ var toggleFold = function (k) { setFolded(Object.assign({}, folded, { [k]: !folded[k] })) }
1778
+ var toggleCard = function (id) { setExpanded(Object.assign({}, expanded, { [id]: !expanded[id] })) }
1779
+ var matchQ = function (c) {
1780
+ if (!q.trim()) return true
1781
+ var s = q.trim().toLowerCase()
1782
+ return (c.title + ' ' + (c.docTitle || '') + ' ' + (c.preview || '') + ' ' + (c.tags || []).join(' ') + ' ' + (c.sectionTags || []).join(' ')).toLowerCase().indexOf(s) >= 0
1783
+ }
1784
+ var isStack = layout === 'stack'
1785
+ var sum = zh
1786
+ ? ('共 ' + total + ' 张小节卡' + (stats.omitted ? '(另有 ' + stats.omitted + ' 张已省略)' : '') + ' · 有锚点 ' + (stats.anchored || 0) + ' · 来源 ' + (stats.cardSource === 'section' ? '按小节切分' : '按文件'))
1787
+ : (total + ' section cards' + (stats.omitted ? ' (' + stats.omitted + ' omitted)' : '') + ' · anchored ' + (stats.anchored || 0))
1788
+
1789
+ var btn = function (label, onClick, title) {
1790
+ return h('button', { 'data-dam-btn': '', onClick: onClick, title: title || '', style: { padding: '1px 7px', fontSize: '11px' } }, label)
1791
+ }
1792
+
1793
+ // 工具条: 缩放 / 布局 / 视图 / 搜索
1794
+ var bar = h('div', { 'data-dam-kbar': '', style: { display: 'flex', flexWrap: 'wrap', gap: '5px', alignItems: 'center', marginBottom: '8px' } }, [
1795
+ btn('A-', function () { setZoomBoth(-0.1) }, zh ? '缩小字号' : 'smaller'),
1796
+ h('span', { 'data-dam-hint': '', style: { fontSize: '10.5px', minWidth: '36px', textAlign: 'center' } }, Math.round(zoom * 100) + '%'),
1797
+ btn('A+', function () { setZoomBoth(0.1) }, zh ? '放大字号' : 'bigger'),
1798
+ h('span', { style: { width: '6px' } }),
1799
+ btn('W-', function () { setColBoth(-40) }, zh ? '收窄列宽' : 'narrower'),
1800
+ h('span', { 'data-dam-hint': '', style: { fontSize: '10.5px', minWidth: '44px', textAlign: 'center' } }, isStack ? '' : colW + 'px'),
1801
+ btn('W+', function () { setColBoth(40) }, zh ? '加宽列宽' : 'wider'),
1802
+ h('span', { style: { width: '6px' } }),
1803
+ btn(isStack ? (zh ? '纵向' : 'stack') : (zh ? '横向' : 'lane'), toggleLayout, zh ? '切换泳道排布' : 'toggle layout'),
1804
+ btn(view === 'lanes' ? (zh ? '泳道' : 'lanes') : (zh ? '最近' : 'recent'), function () { setView(view === 'lanes' ? 'recent' : 'lanes') }, zh ? '切换视图' : 'toggle view'),
1805
+ h('input', {
1806
+ 'data-dam-input': '', value: q, placeholder: zh ? '搜索…' : 'search…',
1807
+ onChange: function (e) { setQ(e.target.value) },
1808
+ style: { flex: '1 1 90px', minWidth: '80px', fontSize: '11px', padding: '2px 6px' },
1809
+ }),
1810
+ ])
1811
+
1812
+ var renderCard = function (c, i) {
1813
+ return h(KanbanCard, { key: c.id + '#' + i, card: c, zoom: zoom, expanded: !!expanded[c.id], onToggle: toggleCard })
1814
+ }
1815
+
1816
+ // 最近视图(P9): 跨泳道按 mtime 倒序
1817
+ if (view === 'recent') {
1818
+ var rec = (kb.recent || []).filter(matchQ)
1819
+ return h('div', null, [
1820
+ h('div', { 'data-dam-hint': '', style: { marginBottom: '6px' } }, sum),
1821
+ bar,
1822
+ rec.length
1823
+ ? h('div', { style: { columnWidth: isStack ? 'auto' : colW + 'px' } }, rec.map(function (c, i) {
1824
+ return h('div', { key: c.id + '#' + i }, [
1825
+ h('div', { 'data-dam-hint': '', style: { fontSize: '10px', marginTop: '4px' } }, (c.laneLabel || '') + ' · ' + (c.source || '')),
1826
+ renderCard(c, i),
1827
+ ])
1828
+ }))
1829
+ : h('div', { 'data-dam-hint': '' }, zh ? '无命中' : 'no match'),
1830
+ ])
1831
+ }
1832
+
1833
+ // 泳道视图: 纵向堆叠(默认, 适配 440px 面板) 或 横向泳道
1834
+ var wrapStyle = isStack
1835
+ ? { display: 'block' }
1836
+ : { display: 'flex', gap: '10px', overflowX: 'auto', alignItems: 'flex-start', paddingBottom: '6px' }
1837
+ var laneStyle = isStack
1838
+ ? { border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.22)) 55%, transparent)', borderRadius: '10px', padding: '8px', background: 'color-mix(in srgb, currentColor 2.5%, transparent)', marginBottom: '9px' }
1839
+ : { flex: '0 0 auto', width: colW + 'px', minWidth: '160px', border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.22)) 55%, transparent)', borderRadius: '10px', padding: '8px', background: 'color-mix(in srgb, currentColor 2.5%, transparent)' }
1840
+
1841
+ return h('div', null, [
1842
+ h('div', { 'data-dam-hint': '', style: { marginBottom: '6px', wordBreak: 'break-word' } }, sum),
1843
+ bar,
1844
+ h('div', { 'data-dam-kanban': '', 'data-dam-layout': layout, style: wrapStyle },
1845
+ lanes.map(function (lane) {
1846
+ var cards = (lane.cards || []).filter(matchQ)
1847
+ var isFolded = !!folded[lane.key]
1848
+ var empty = !lane.count
1849
+ // P14: 空泳道默认收起(不占位), 但仍给出可点开的标题
1850
+ return h('div', { key: lane.key, 'data-dam-lane': lane.key, style: Object.assign({}, laneStyle, empty ? { opacity: .55 } : {}) }, [
1851
+ h('div', {
1852
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: isFolded ? '0' : '7px', cursor: 'pointer', gap: '6px' },
1853
+ onClick: function () { toggleFold(lane.key) },
1854
+ }, [
1855
+ h('div', { style: { fontWeight: 700, fontSize: (12 * zoom) + 'px' } },
1856
+ (isFolded ? '▸ ' : '▾ ') + lane.label),
1857
+ h('span', { 'data-dam-hint': '', style: { fontSize: (10.5 * zoom) + 'px', whiteSpace: 'nowrap' } },
1858
+ String(lane.count || 0) + (q.trim() && cards.length !== lane.count ? ' (' + cards.length + ')' : '') + (lane.omitted ? ' +' + lane.omitted : '')),
1859
+ ]),
1860
+ isFolded ? null : (cards.length
1861
+ ? h('div', isStack ? { style: { columnWidth: colW + 'px', columnGap: '10px' } } : null,
1862
+ cards.map(function (c, i) { return renderCard(c, i) }))
1863
+ : h('div', { 'data-dam-hint': '', style: { padding: '4px 0', fontSize: '11px' } },
1864
+ empty ? (zh ? '(本泳道暂无内容)' : '(empty)') : (zh ? '无命中' : 'no match'))),
1865
+ ])
1866
+ })),
1867
+ ])
1868
+ }
1869
+
1870
+ // ══════════════════════════════════════════════════════════════════════════
1871
+ // 整页白板看板(2026-09-16「双承载面」批) —— 注册在 conversation.view 槽位。
1872
+ //
1873
+ // 为什么需要它: 侧边面板固定 440px, 而矩阵式布局(首列行标签 + N 列)需要 ~1000px+。
1874
+ // 把矩阵塞进 440px 就是「大小不可调 / 看着挤」的结构性根因 ⇒ 宽容器另开一个承载面。
1875
+ //
1876
+ // 与面板的关系(两者共存, 非替换):
1877
+ // · 侧边面板 = 常驻入口, 窄, 用**列表视图**(KanbanBoard, 泳道堆叠);
1878
+ // · 整页看板 = 会话页顶栏 Tab, 宽, 用**矩阵视图**(行=日期分组, 列=小节类型)。
1879
+ // 同一份数据(/kanban-board 一个路由同时回 lanes 与 matrix), 按容器宽度选形态。
1880
+ //
1881
+ // 交互: 行折叠 + 卡片点开右侧抽屉(宽屏才放得下全文, 这才是有别于面板的价值)。
1882
+ // ══════════════════════════════════════════════════════════════════════════
1883
+
1884
+ // 泳道配色(2026-09-16): 每条泳道一个色相, 让「一眼看出哪列是什么」。
1885
+ // 取值优先走官方令牌(跟随宿主明暗主题), 令牌缺失时回落到可读色值。
1886
+ var KX_LANE_COLOR = {
1887
+ goal: 'var(--dsw-alias-brand-primary, #4c8dff)', // 目标 · 蓝
1888
+ state: 'var(--dsw-alias-state-success-primary, #3aa675)', // 进行中 · 绿
1889
+ deadend: 'var(--dsw-alias-state-error-primary, #d66666)', // 失败与弯路 · 红
1890
+ progress: 'var(--dsw-alias-state-warn-primary, #e0a53a)', // 进度与下一步 · 橙
1891
+ archive: 'var(--dsw-alias-label-tertiary, #8b93a7)', // 版本归档 · 灰蓝
1892
+ misc: 'var(--dsw-alias-label-dimmed, #6b7280)', // 其它 · 暗灰
1893
+ }
1894
+ function kxLaneColor(key) { return KX_LANE_COLOR[key] || KX_LANE_COLOR.misc }
1895
+
1896
+ /** 判据徽章(与面板同口径, 抽出来避免两处漂移)。 */
1897
+ function kxBadge(crit, zh) {
1898
+ var m = {
1899
+ passed: ['✓', zh ? '通过' : 'passed', 'rgba(58,166,117,.20)', '#3aa675'],
1900
+ warned: ['!', zh ? '有警告' : 'warned', 'rgba(224,165,58,.20)', '#e0a53a'],
1901
+ failed: ['✕', zh ? '未通过' : 'failed', 'rgba(214,102,102,.20)', '#d66666'],
1902
+ unknown: ['·', zh ? '未校验' : 'unchecked', 'rgba(128,128,128,.18)', 'inherit'],
1903
+ }
1904
+ var v = m[crit] || m.unknown
1905
+ return h('span', {
1906
+ 'data-dam-kx-badge': crit,
1907
+ title: v[1],
1908
+ style: { flexShrink: 0, fontSize: '10px', padding: '0 4px', borderRadius: '3px', background: v[2], color: v[3] },
1909
+ }, v[0])
1910
+ }
1911
+
1912
+ /** 矩阵里的紧凑卡(只给全貌, 全文进抽屉)。 */
1913
+ function KanbanMatrixCard(props) {
1914
+ var c = props.card
1915
+ var zh = props.zh
1916
+ // 左侧色条按**泳道**取色(不是按有无 tag): 这样同一列内颜色一致, 跨列一眼可辨。
1917
+ var accent = kxLaneColor(props.laneKey)
1918
+ return h('div', {
1919
+ 'data-dam-kx-card': c.id,
1920
+ 'data-dam-kx-lane': props.laneKey || '',
1921
+ title: c.title + ' · ' + c.docTitle,
1922
+ onClick: props.onOpen,
1923
+ style: {
1924
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.28))',
1925
+ borderLeft: '3px solid ' + accent,
1926
+ borderRadius: '5px', padding: '6px 8px', marginBottom: '5px', cursor: 'pointer',
1927
+ background: 'var(--dsw-alias-bg-layer-3, rgba(128,128,128,.07))',
1928
+ transition: 'transform .12s ease, box-shadow .12s ease, border-color .12s ease',
1929
+ },
1930
+ onMouseEnter: function (e) {
1931
+ e.currentTarget.style.transform = 'translateY(-1px)'
1932
+ e.currentTarget.style.boxShadow = '0 2px 10px var(--dsw-alias-bg-mask-1, rgba(0,0,0,.18))'
1933
+ e.currentTarget.style.borderColor = accent
1934
+ },
1935
+ onMouseLeave: function (e) {
1936
+ e.currentTarget.style.transform = ''
1937
+ e.currentTarget.style.boxShadow = ''
1938
+ e.currentTarget.style.borderColor = 'var(--dsw-alias-border-l1, rgba(128,128,128,.28))'
1939
+ },
1940
+ }, [
1941
+ h('div', { key: 't', style: { display: 'flex', alignItems: 'center', gap: '5px' } }, [
1942
+ kxBadge(c.criteria, zh),
1943
+ h('span', { key: 'n', style: {
1944
+ flex: 1, minWidth: 0, fontSize: '12.5px', fontWeight: 600,
1945
+ color: 'var(--dsw-alias-label-primary)',
1946
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
1947
+ } }, c.title),
1948
+ c.bullets ? h('span', { key: 'b', 'data-dam-hint': '', style: { fontSize: '10.5px', flexShrink: 0 } }, '·' + c.bullets) : null,
1949
+ c.anchored ? h('span', { key: 'a', title: '已有锚点', style: { fontSize: '10.5px', flexShrink: 0, color: 'var(--dsw-alias-state-success-primary, #3aa675)' } }, '⚓') : null,
1950
+ ]),
1951
+ c.preview ? h('div', { key: 'p', 'data-dam-hint': '', style: {
1952
+ fontSize: '11px', lineHeight: 1.45, marginTop: '4px',
1953
+ color: 'var(--dsw-alias-label-secondary)',
1954
+ overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
1955
+ } }, c.preview) : null,
1956
+ ])
1957
+ }
1958
+
1959
+ /** 整页矩阵看板: 行=日期分组, 列=小节类型。 */
1960
+ function KanbanView(props) {
1961
+ var st = useState(null), data = st[0], setData = st[1]
1962
+ // ★2026-09-17(L2 修 bug①): 额外保留**原始载荷** —— 旧实现 `setData(k && k.enabled ? k : null)`
1963
+ // 直接把宿主返回的 reason/error 丢掉了, 于是下面只能硬编码一句猜测式提示。
1964
+ var stPay = useState(null), pay = stPay[0], setPay = stPay[1]
1965
+ var st2 = useState(null), drawer = st2[0], setDrawer = st2[1]
1966
+ var st3 = useState({}), folded = st3[0], setFolded = st3[1]
1967
+ var st4 = useState(''), q = st4[0], setQ = st4[1]
1968
+ var st5 = useState(1), zoom = st5[0], setZoom = st5[1]
1969
+ var st6 = useState(0), tick = st6[0], bump = st6[1]
1970
+ var zh = (locale || 'zh').indexOf('zh') === 0
1971
+ useEffect(function () {
1972
+ var alive = true
1973
+ apiGet(API.kanbanBoard, { sessionId: currentSessionIdClient() })
1974
+ .then(function (k) { if (alive) { setPay(k || null); setData(k && k.enabled ? k : null) } })
1975
+ .catch(function (e) { if (alive) { setPay({ reason: 'error', error: String((e && e.message) || e) }); setData(null) } })
1976
+ return function () { alive = false }
1977
+ }, [tick])
1978
+
1979
+ var mx = data && data.matrix
1980
+ var cols = (mx && mx.columns) || []
1981
+ var rows = (mx && mx.rows) || []
1982
+ var kw = q.trim().toLowerCase()
1983
+ var hit = function (c) { return !kw || String(c.title + ' ' + c.preview + ' ' + c.docTitle).toLowerCase().indexOf(kw) >= 0 }
1984
+
1985
+ var total = (mx && mx.stats && mx.stats.totalCards) || 0
1986
+ var missing = (mx && mx.stats && mx.stats.missingDeadend) || []
1987
+
1988
+ // ★2026-09-17(L2 修 bug①): **按宿主返回的真实 reason 分支**, 不再硬编码猜测式提示。
1989
+ // 旧文案把「看板未启用」归因于 **handoff 未开启**, 把用户指向错误方向 —— 用户报告:
1990
+ // 「明明已经启用了, 却显示未启用或加载失败」。现消费 payload 里的 reason/error。
1991
+ var emptyMsg = (function () {
1992
+ var r = pay && pay.reason
1993
+ if (r === 'legacy-mode') {
1994
+ return zh ? '当前是旧版白板(legacy)档。切到新版看板: 设置 → 自动记忆引擎 → 看板模式 → 「新版看板(dsh-graph)」,重启 dsh web 后生效。'
1995
+ : 'Legacy board mode. Switch to graph board: Settings → Auto Memory Engine → Board mode → "Graph board (dsh-graph)", then restart dsh web.'
1996
+ }
1997
+ if (r === 'error') {
1998
+ var m = String((pay && pay.error) || '')
1999
+ return (zh ? '看板加载出错: ' : 'Board load error: ') + (m || (zh ? '(无错误详情)' : '(no detail)'))
2000
+ }
2001
+ if (r === 'handoff-disabled') {
2002
+ // 兼容极旧宿主(本版后端已不再返回该 reason), 如实说明而非误导
2003
+ return zh ? '看板数据源已关闭。请在设置里启用白板/账本(handoff)。' : 'Board data source is off. Enable whiteboard/ledger (handoff) in settings.'
2004
+ }
2005
+ return zh ? '看板尚未就绪或加载失败。请点上方「刷新」重试; 若持续如此, 检查是否已切到新版看板(dsh-graph)档。'
2006
+ : 'Board not ready or failed to load. Click "reload" above; if it persists, check the graph board mode.'
2007
+ })()
2008
+
2009
+ return h('div', { 'data-dam-kanban-view': '', style: {
2010
+ // position:relative 是**必需**的: 抽屉用 absolute 定位, 它的参照系就是这里 ⇒ 抽屉被裁剪在
2011
+ // 看板承载面内, 不会像 fixed 那样越界盖住底部输入框。overflow:hidden 同时兜底裁剪。
2012
+ position: 'relative', height: '100%', overflow: 'hidden', boxSizing: 'border-box',
2013
+ color: 'var(--dsw-alias-label-primary)', fontSize: '12px',
2014
+ } }, [
2015
+ // 内滚动层(与抽屉分离: 抽屉不该跟着内容一起滚)
2016
+ h('div', { 'data-dam-kx-scroll': '', style: { height: '100%', overflow: 'auto', padding: '12px 16px', boxSizing: 'border-box' } }, [
2017
+ // ── 概览条 ──
2018
+ h('div', { key: 'hdr', style: { display: 'flex', alignItems: 'baseline', gap: '10px', flexWrap: 'wrap', marginBottom: '8px' } }, [
2019
+ h('strong', { key: 't', style: { fontSize: '14px' } }, zh ? '白板看板 · 时间矩阵' : 'Whiteboard · time matrix'),
2020
+ h('span', { key: 's', 'data-dam-hint': '' },
2021
+ zh
2022
+ ? (String(total) + ' 张小节卡 / ' + String(rows.length) + ' 天 / ' + (mx && mx.stats ? mx.stats.dateFrom + ' → ' + mx.stats.dateTo : ''))
2023
+ : (String(total) + ' cards / ' + String(rows.length) + ' days')),
2024
+ missing.length
2025
+ ? h('span', { key: 'm', 'data-dam-kx-warn': '', title: missing.join(', '), style: { color: '#e0a53a' } },
2026
+ zh ? ('⚠ ' + missing.length + ' 天缺「失败与弯路」') : ('⚠ ' + missing.length + ' days missing dead-ends'))
2027
+ : null,
2028
+ ]),
2029
+ // ── 工具条 ──
2030
+ h('div', { key: 'bar', style: { display: 'flex', gap: '6px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' } }, [
2031
+ h('input', { key: 'q', 'data-dam-input': '', placeholder: zh ? '搜索标题/正文/来源…' : 'search…', value: q, onChange: function (e) { setQ(e.target.value) }, style: { minWidth: '200px' } }),
2032
+ h('button', { key: 'zo', 'data-dam-btn': '', onClick: function () { setZoom(Math.max(0.8, zoom - 0.1)) } }, 'A-'),
2033
+ h('span', { key: 'zv', 'data-dam-hint': '', style: { minWidth: '38px', textAlign: 'center' } }, Math.round(zoom * 100) + '%'),
2034
+ h('button', { key: 'zi', 'data-dam-btn': '', onClick: function () { setZoom(Math.min(1.6, zoom + 0.1)) } }, 'A+'),
2035
+ h('button', { key: 'rf', 'data-dam-btn': '', onClick: function () { setData(null); bump(function (n) { return n + 1 }) } }, zh ? '刷新' : 'reload'),
2036
+ ]),
2037
+ // ── 空态 / 错误态(不静默; ★2026-09-17 按真实 reason 分支, 见上方 emptyMsg) ──
2038
+ !data ? h('div', { key: 'ph', 'data-dam-kx-empty': 'nodata', 'data-dam-hint': '' }, emptyMsg) : null,
2039
+ data && !mx ? h('div', { key: 'nm', 'data-dam-kx-empty': 'nomatrix', 'data-dam-hint': '' },
2040
+ zh ? '本次载荷缺矩阵视图。' : 'No matrix in payload.') : null,
2041
+ data && mx && !rows.length ? h('div', { key: 'nr', 'data-dam-kx-empty': 'norows', 'data-dam-hint': '' },
2042
+ zh ? '白板里还没有账本文档。' : 'No ledger docs yet.') : null,
2043
+ // ── 矩阵本体 ──
2044
+ data && mx && rows.length ? h('div', {
2045
+ key: 'grid',
2046
+ style: {
2047
+ display: 'grid',
2048
+ // 首列 170px 行标签(日期要放得下) + N 列自适应
2049
+ gridTemplateColumns: '170px repeat(' + cols.length + ', minmax(190px, 1fr))',
2050
+ gap: '6px',
2051
+ fontSize: (12 * zoom) + 'px',
2052
+ },
2053
+ }, [].concat(
2054
+ // 表头: 与内容区用底色 + 下边框拉开层次, sticky 吸顶
2055
+ [h('div', { key: 'hc0', 'data-dam-kx-head': 'row', style: {
2056
+ fontWeight: 700, padding: '8px 6px', position: 'sticky', top: 0, zIndex: 3,
2057
+ fontSize: '11.5px', letterSpacing: '.03em', textTransform: 'uppercase',
2058
+ color: 'var(--dsw-alias-label-tertiary)',
2059
+ background: 'var(--dsw-alias-bg-layer-1)',
2060
+ borderBottom: '2px solid var(--dsw-alias-border-l2, rgba(128,128,128,.45))',
2061
+ } }, zh ? '日期' : 'Date')],
2062
+ cols.map(function (c) {
2063
+ return h('div', { key: 'hc-' + c.key, 'data-dam-kx-head': c.key, style: {
2064
+ fontWeight: 700, padding: '8px 6px', display: 'flex', gap: '6px', alignItems: 'center',
2065
+ position: 'sticky', top: 0, zIndex: 3,
2066
+ color: 'var(--dsw-alias-label-primary)',
2067
+ background: 'var(--dsw-alias-bg-layer-1)',
2068
+ borderBottom: '2px solid ' + kxLaneColor(c.key),
2069
+ } }, [
2070
+ // 顶部色点 = 该列身份, 与卡片左条同色 ⇒ 列↔卡片的归属一眼可辨
2071
+ h('span', { key: 'dot', 'data-dam-kx-dot': c.key, style: {
2072
+ width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0,
2073
+ background: kxLaneColor(c.key), boxShadow: '0 0 0 2px ' + kxLaneColor(c.key) + '33',
2074
+ } }),
2075
+ h('span', { key: 'l', style: { flex: 1, minWidth: 0 } }, c.label),
2076
+ h('span', { key: 'n', 'data-dam-hint': '', style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, String(c.count || 0)),
2077
+ ])
2078
+ }),
2079
+ // 数据行
2080
+ rows.reduce(function (acc, r) {
2081
+ var isFolded = !!folded[r.key]
2082
+ acc.push(h('div', {
2083
+ key: 'rl-' + r.key,
2084
+ 'data-dam-kx-row': r.key,
2085
+ onClick: function () { setFolded(function (f) { var n = Object.assign({}, f); n[r.key] = !n[r.key]; return n }) },
2086
+ style: {
2087
+ padding: '8px 6px', cursor: 'pointer', userSelect: 'none',
2088
+ fontWeight: 700, fontSize: '12.5px',
2089
+ color: 'var(--dsw-alias-label-primary)',
2090
+ background: 'var(--dsw-alias-bg-layer-2)',
2091
+ borderRadius: '5px',
2092
+ borderTop: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.28))',
2093
+ },
2094
+ title: zh ? '点击折叠/展开该日' : 'toggle',
2095
+ }, (isFolded ? '▸ ' : '▾ ') + r.label + ' (' + String(r.docs) + ')'))
2096
+ cols.forEach(function (c) {
2097
+ var cells = ((r.cells || {})[c.key] || []).filter(hit)
2098
+ acc.push(h('div', {
2099
+ key: 'rc-' + r.key + '-' + c.key,
2100
+ 'data-dam-kx-cell': c.key,
2101
+ style: {
2102
+ padding: '3px 2px', minWidth: 0,
2103
+ borderTop: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.18))',
2104
+ // 极淡的泳道底色(color-mix 把主色压到 ~5%): 让整列连成一片而不抢内容
2105
+ background: 'color-mix(in srgb, ' + kxLaneColor(c.key) + ' 5%, transparent)',
2106
+ },
2107
+ }, isFolded ? null : (cells.length
2108
+ ? cells.map(function (c2) { return h(KanbanMatrixCard, { key: c2.id, card: c2, zh: zh, laneKey: c.key, onOpen: function () { setDrawer(c2) } }) })
2109
+ : h('div', { 'data-dam-hint': '', style: { fontSize: '10px', padding: '2px 4px', opacity: .5 } }, kw ? '—' : ''))))
2110
+ })
2111
+ return acc
2112
+ }, [])
2113
+ )) : null,
2114
+ ]), // ← 闭合内滚动层(data-dam-kx-scroll)
2115
+ // ── 右侧浮窗(portal 到 body) ──
2116
+ // ★ 2026-09-16 二次修正: 上一版改成容器内 absolute 后, **页面往下滚它就被推出视口**
2117
+ // (用户实测「在页面下部分没办法看到点开的页签」)。absolute 的参照系是容器, 容器会滚。
2118
+ // 正解 = createPortal 挂到 document.body + position:fixed ⇒ 参照系是视口, 永远可见,
2119
+ // 且因为脱离容器, 不会被容器的 overflow 裁剪。底部留 96px 安全边距避开输入区。
2120
+ drawer ? kxPortal(h('div', {
2121
+ key: 'dr',
2122
+ 'data-dam-kx-drawer': drawer.id,
2123
+ style: {
2124
+ position: 'fixed', top: '52px', right: '16px', bottom: '96px',
2125
+ width: 'min(560px, 42vw)', zIndex: 2147483000,
2126
+ display: 'flex', flexDirection: 'column',
2127
+ background: 'var(--dsw-alias-bg-layer-2, rgba(24,24,28,.97))', backdropFilter: 'blur(16px)',
2128
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35))',
2129
+ borderRadius: '10px',
2130
+ boxShadow: '0 12px 40px var(--dsw-alias-bg-mask-1, rgba(0,0,0,.35))',
2131
+ color: 'var(--dsw-alias-label-primary)', fontSize: '12.5px', lineHeight: 1.6,
2132
+ overflow: 'hidden',
2133
+ },
2134
+ }, [
2135
+ h('div', { key: 'h', style: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' } }, [
2136
+ kxBadge(drawer.criteria, zh),
2137
+ h('strong', { key: 't', style: { flex: 1, fontSize: '14px' } }, drawer.title),
2138
+ h('button', { key: 'x', 'data-dam-btn': '', onClick: function () { setDrawer(null) } }, '✕'),
2139
+ ]),
2140
+ h('div', { key: 'meta', 'data-dam-hint': '', style: { marginBottom: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap' } }, [
2141
+ h('span', { key: 'd' }, drawer.docTitle),
2142
+ h('span', { key: 's' }, drawer.source),
2143
+ h('span', { key: 'i' }, '#' + drawer.short),
2144
+ drawer.anchored ? h('span', { key: 'a', title: drawer.anchorId }, '⚓ ' + String(drawer.anchorId).slice(0, 14)) : null,
2145
+ ]),
2146
+ (drawer.sectionTags && drawer.sectionTags.length)
2147
+ ? h('div', { key: 'sg', style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: '8px' } },
2148
+ drawer.sectionTags.map(function (tg, i) { return h('span', { key: i, 'data-dam-chip': '', style: { fontSize: '10px', padding: '1px 5px', borderRadius: '3px', background: 'rgba(76,141,255,.18)' } }, tg) }))
2149
+ : null,
2150
+ h('pre', { key: 'body', 'data-dam-kx-full': drawer.id, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'inherit', margin: 0, fontSize: '12px' } }, drawer.full || drawer.preview || ''),
2151
+ ].map(function (node, i) {
2152
+ // 头部固定、正文滚动: 把内容各自包一层; 正文那层给 flex:1 + overflow:auto
2153
+ if (i === 0) return h('div', { key: 'headwrap', style: { padding: '12px 14px 8px', flexShrink: 0 } }, node)
2154
+ if (i === 1) return h('div', { key: 'metawrap', style: { padding: '0 14px', flexShrink: 0 } }, node)
2155
+ if (i === 2) return node ? h('div', { key: 'tagwrap', style: { padding: '0 14px', flexShrink: 0 } }, node) : null
2156
+ return h('div', { key: 'bodywrap', style: { padding: '8px 14px 14px', flex: 1, overflow: 'auto', minHeight: 0 } }, node)
2157
+ }))) : null,
2158
+ ])
2159
+ }
2160
+
2161
+ // ══════════════════════════════════════════════════════════════════════════
2162
+ // 白板节点图画布(2026-09-20 重构批 · 风格参考 kanghelyu/dsh-deepseek-flow)
2163
+ //
2164
+ // 立场: **只改渲染层**。数据源仍是同一个 /kanban-board 载荷, 后端与 sidecar 零改动。
2165
+ // 参考项目用 esbuild 打包 8 个模块, 本插件受「单文件 / 无构建链」约束
2166
+ // (package.json exports["./client"]='./lib/client.js' 直送浏览器) ⇒
2167
+ // 不引第三方图库, 布局算法手写。
2168
+ //
2169
+ // 与泳道看板的关系(共存, 非替换):
2170
+ // · 会话页整页宽(≥700px) = 本画布(节点 + 连线, 平移缩放);
2171
+ // · 侧边面板 440px = 原泳道看板(装不下画布, 保持不动)。
2172
+ //
2173
+ // 图要素(全部来自现有数据, §6 侦察报告结论):
2174
+ // · 节点 = 小节卡(WbShape 清洗后: title 为正文首行, kicker 为小节名)
2175
+ // · 结构边 = 同泳道内的时间/顺序链
2176
+ // · 分层 = 按**泳道**分列(有限 6 类), 列内按时间倒序排列
2177
+ // ⚠ 语义边(tag 关联/版本链)数据当前不可用(by_tag 全 sec: / versionCount 恒 0),
2178
+ // 故本版**不画**这类边 —— 宁可少画, 不画假的。
2179
+ // ──────────────────────────────────────────────────────────────────────────
2180
+ var WBG_NODE_W = 232
2181
+ var WBG_NODE_H = 104
2182
+ var WBG_GAP_X = 74
2183
+ var WBG_GAP_Y = 22
2184
+ var WBG_PAD = 40
2185
+ var WBG_ZOOM_KEY = 'dam-wbg-zoom'
2186
+ var WBG_PAN_KEY = 'dam-wbg-pan'
2187
+ // 每列最多排多少个节点, 其余折叠为「+N」汇总节点。
2188
+ //
2189
+ // ★2026-09-21 布局重做(实测驱动): 原实现「一篇文档 = 一列」在真实数据下**数学上不可用** ——
2190
+ // 本仓库实测 876 节点 / 189 篇文档 ⇒ 画布 57,840 × 1,318 px, 宽高比 **43.9:1**
2191
+ // (容器仅 1.72:1), 理想 fit 需缩到 **1.9%**, 而缩放下限 35% ⇒ **永远看不到全貌,
2192
+ // 要横向拖 52 屏**。按泳道分列同样不行(1,842 × 23,494 ⇒ 纵向 37 屏)。
2193
+ // 结论: 病根不是「按什么分组」, 而是**一屏塞不下 876 个节点**。
2194
+ // 人的看图逻辑是「先全局分类 → 再逐层下钻」⇒ 默认视图必须是**概览**而非全量平铺。
2195
+ var WBG_COL_CAP = 14
2196
+ var WBG_MIN_ZOOM = 0.08
2197
+ var WBG_HDR_H = 34
2198
+
2199
+ /**
2200
+ * 分层布局: 列 = 泳道(类型), 行 = 时间倒序。
2201
+ *
2202
+ * 为什么按泳道而非文档: 泳道是**有限的 6 类**(goal/state/deadend/progress/archive/misc),
2203
+ * 语义稳定、用户已知; 文档是 189 个且会持续增长 —— 用它当轴等于把画布宽度绑死在数据量上。
2204
+ * 每列内按时间倒序(最近的在最上面), 超出 WBG_COL_CAP 的折叠为汇总节点,
2205
+ * 既保证一屏可读, 又不隐藏数据(可搜索或切「白板看板」看全部)。
2206
+ */
2207
+ function wbLayout(cards) {
2208
+ var byLane = {}
2209
+ for (var i = 0; i < cards.length; i++) {
2210
+ var k = cards[i].laneKey || cards[i].lane || 'misc'
2211
+ if (!byLane[k]) byLane[k] = []
2212
+ byLane[k].push(cards[i])
2213
+ }
2214
+ // 固定列序(与面板泳道语义一致), 未在表中出现的追加在后
2215
+ var ORDER = ['goal', 'state', 'deadend', 'progress', 'archive', 'misc']
2216
+ var keys = []
2217
+ for (var oi = 0; oi < ORDER.length; oi++) if (byLane[ORDER[oi]]) keys.push(ORDER[oi])
2218
+ for (var kk in byLane) if (keys.indexOf(kk) < 0) keys.push(kk)
2219
+
2220
+ var nodes = []
2221
+ var edges = []
2222
+ var groups = []
2223
+ var maxRows = 0
2224
+ for (var ci = 0; ci < keys.length; ci++) {
2225
+ var key = keys[ci]
2226
+ var list = byLane[key].slice().sort(function (a, b) {
2227
+ // 时间新的在上; 无 ts 时按 lineStart 兜底
2228
+ var ta = Number(a.ts) || 0
2229
+ var tb = Number(b.ts) || 0
2230
+ if (ta !== tb) return tb - ta
2231
+ return (Number(a.lineStart) || 0) - (Number(b.lineStart) || 0)
2232
+ })
2233
+ var shown = list.slice(0, WBG_COL_CAP)
2234
+ var hidden = list.length - shown.length
2235
+ var colX = WBG_PAD + ci * (WBG_NODE_W + WBG_GAP_X)
2236
+ groups.push({ key: key, x: colX, total: list.length, shownN: shown.length, hidden: hidden })
2237
+ var rows = shown.length + (hidden > 0 ? 1 : 0)
2238
+ if (rows > maxRows) maxRows = rows
2239
+ for (var ri = 0; ri < shown.length; ri++) {
2240
+ var nd = wbShape(shown[ri])
2241
+ nd.x = colX
2242
+ nd.y = WBG_PAD + WBG_HDR_H + ri * (WBG_NODE_H + WBG_GAP_Y)
2243
+ nd.laneKey = key
2244
+ nd.col = ci
2245
+ nd.row = ri
2246
+ nodes.push(nd)
2247
+ // 结构边: 同泳道内相邻节点相连(时间链)
2248
+ if (ri > 0) edges.push({ from: shown[ri - 1].id, to: shown[ri].id, key: key })
2249
+ }
2250
+ if (hidden > 0) {
2251
+ nodes.push({
2252
+ id: '__more__' + key, x: colX,
2253
+ y: WBG_PAD + WBG_HDR_H + shown.length * (WBG_NODE_H + WBG_GAP_Y),
2254
+ title: (zh ? '还有 ' + hidden + ' 条' : '+' + hidden + ' more'),
2255
+ body: zh ? '已折叠以免画布过长 —— 用上方搜索, 或切到「白板看板」看全部' : 'collapsed for readability',
2256
+ kicker: '', docTitle: '', laneKey: key, col: ci, row: shown.length, isMore: true,
2257
+ })
2258
+ }
2259
+ }
2260
+ return {
2261
+ nodes: nodes,
2262
+ edges: edges,
2263
+ groups: groups,
2264
+ width: WBG_PAD * 2 + keys.length * WBG_NODE_W + Math.max(0, keys.length - 1) * WBG_GAP_X,
2265
+ height: WBG_PAD * 2 + WBG_HDR_H + maxRows * WBG_NODE_H + Math.max(0, maxRows - 1) * WBG_GAP_Y,
2266
+ keys: keys,
2267
+ totalCards: cards.length,
2268
+ }
2269
+ }
2270
+
2271
+ /** 节点卡: 标题(正文首行) + 小节名 kicker + 正文摘要。四段式。 */
2272
+ function WbgNode(props) {
2273
+ var n = props.node
2274
+ var zoom = Number(props.zoom) || 1
2275
+ var sel = !!props.selected
2276
+ var tone = wbLaneTone(n.laneKey || n.section || '')
2277
+ var body = n.body || ''
2278
+ if (body.length > 220) body = body.slice(0, 217) + '…'
2279
+ var anchor = props.anchor
2280
+ return h('div', {
2281
+ 'data-dam-wbg-node': '',
2282
+ 'data-selected': sel ? 'true' : 'false',
2283
+ onClick: function (e) { e.stopPropagation(); props.onPick(n.id) },
2284
+ style: {
2285
+ position: 'absolute',
2286
+ left: n.x + 'px', top: n.y + 'px',
2287
+ width: WBG_NODE_W + 'px', minHeight: WBG_NODE_H + 'px',
2288
+ boxSizing: 'border-box',
2289
+ padding: '9px 11px 10px',
2290
+ borderRadius: '10px',
2291
+ cursor: 'pointer',
2292
+ background: sel
2293
+ ? 'color-mix(in srgb, ' + tone + ' 16%, var(--dsw-alias-bg-layer-2, rgba(22,22,26,.96)))'
2294
+ : 'var(--dsw-alias-bg-layer-2, rgba(22,22,26,.92))',
2295
+ border: '1px solid ' + (sel ? tone : 'color-mix(in srgb, ' + tone + ' 34%, transparent)'),
2296
+ boxShadow: sel ? ('0 0 0 1px ' + tone + ', 0 6px 22px color-mix(in srgb, ' + tone + ' 26%, transparent)') : '0 2px 10px rgba(0,0,0,.18)',
2297
+ transition: 'background .14s ease, border-color .14s ease',
2298
+ },
2299
+ }, [
2300
+ // ① 分类条(kicker): 小节名 —— 短、可扫读
2301
+ n.kicker ? h('div', {
2302
+ key: 'k', 'data-dam-hint': '',
2303
+ style: {
2304
+ fontSize: (9.5 * zoom) + 'px', letterSpacing: '.06em', textTransform: 'uppercase',
2305
+ color: tone, opacity: .95, marginBottom: '4px',
2306
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
2307
+ },
2308
+ }, n.kicker + (n.anchored ? ' ⚓' : '')) : null,
2309
+ // ② 标题: 清洗后的正文首行(不再是重复的小节名)
2310
+ h('div', {
2311
+ key: 't',
2312
+ style: {
2313
+ fontSize: (12.5 * zoom) + 'px', fontWeight: 700, lineHeight: '1.34',
2314
+ color: 'var(--dsw-alias-label-primary)',
2315
+ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
2316
+ },
2317
+ }, n.title),
2318
+ // ③ 正文: 与标题不重复的剩余内容
2319
+ body ? h('div', {
2320
+ key: 'b', 'data-dam-hint': '',
2321
+ style: {
2322
+ marginTop: '5px', fontSize: (10.5 * zoom) + 'px', lineHeight: '1.42',
2323
+ color: 'var(--dsw-alias-label-secondary)',
2324
+ display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
2325
+ },
2326
+ }, body) : null,
2327
+ // ④ 来源: 文档名(定位"这条来自哪篇")
2328
+ h('div', {
2329
+ key: 's', 'data-dam-hint': '',
2330
+ style: {
2331
+ marginTop: '6px', fontSize: (9.5 * zoom) + 'px', opacity: .55,
2332
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
2333
+ },
2334
+ }, String(n.docTitle || n.source || '').replace(/^.*[\\/]/, '')),
2335
+ ])
2336
+ }
2337
+
2338
+ /** 画布: 平移(拖拽) + 缩放(滚轮/按钮) + fit + 点选侧栏。零依赖、纯 SVG 连线。 */
2339
+ function WhiteboardGraphView(props) {
2340
+ var st = useState(null), data = st[0], setData = st[1]
2341
+ var stPay = useState(null), pay = stPay[0], setPay = stPay[1]
2342
+ var stTick = useState(0), tick = stTick[0], bump = stTick[1]
2343
+ var stZoom = useState(function () { return readLsNum(WBG_ZOOM_KEY, 1) }), zoom = stZoom[0], setZoom = stZoom[1]
2344
+ var stPan = useState(function () {
2345
+ try { var v = JSON.parse(localStorage.getItem(WBG_PAN_KEY) || 'null'); return v && typeof v.x === 'number' ? v : { x: 0, y: 0 } } catch (e) { return { x: 0, y: 0 } }
2346
+ }), pan = stPan[0], setPan = stPan[1]
2347
+ var stSel = useState(null), sel = stSel[0], setSel = stSel[1]
2348
+ var stQ = useState(''), q = stQ[0], setQ = stQ[1]
2349
+ var dragRef = useRef(null)
2350
+ var wrapRef = useRef(null)
2351
+ var zh = (locale || 'zh').indexOf('zh') === 0
2352
+
2353
+ useEffect(function () {
2354
+ var alive = true
2355
+ apiGet(API.kanbanBoard, { sessionId: currentSessionIdClient() })
2356
+ .then(function (k) { if (alive) { setPay(k || null); setData(k && k.enabled ? k : null) } })
2357
+ .catch(function (e) { if (alive) { setPay({ reason: 'error', error: String((e && e.message) || e) }); setData(null) } })
2358
+ return function () { alive = false }
2359
+ }, [tick])
2360
+
2361
+ // 把 lanes[].cards[] 摊平成一张卡列表(画布不按泳道分组, 泳道只当节点色调)
2362
+ var cards = []
2363
+ var laneOf = {}
2364
+ var lanes = (data && data.lanes) || []
2365
+ for (var li = 0; li < lanes.length; li++) {
2366
+ var lc = lanes[li].cards || []
2367
+ for (var ci = 0; ci < lc.length; ci++) {
2368
+ cards.push(lc[ci])
2369
+ laneOf[lc[ci].id] = lanes[li].key
2370
+ }
2371
+ }
2372
+ var kg = wbLayout(cards.map(function (c) { return Object.assign({}, c, { laneKey: laneOf[c.id] }) }))
2373
+
2374
+ var kw = q.trim().toLowerCase()
2375
+ var matches = function (n) {
2376
+ if (!kw) return true
2377
+ return (n.title + ' ' + n.body + ' ' + n.docTitle + ' ' + n.kicker).toLowerCase().indexOf(kw) >= 0
2378
+ }
2379
+ var visibleNodes = kg.nodes.filter(matches)
2380
+ var visIds = {}
2381
+ for (var vi = 0; vi < visibleNodes.length; vi++) visIds[visibleNodes[vi].id] = 1
2382
+
2383
+ var nodeById = {}
2384
+ for (var ni = 0; ni < kg.nodes.length; ni++) nodeById[kg.nodes[ni].id] = kg.nodes[ni]
2385
+
2386
+ var selNode = sel ? nodeById[sel] : null
2387
+
2388
+ var setZoomBoth = function (v) {
2389
+ var nv = Math.min(2.0, Math.max(WBG_MIN_ZOOM, Math.round(v * 100) / 100))
2390
+ setZoom(nv); writeLs(WBG_ZOOM_KEY, nv)
2391
+ }
2392
+ var fit = function () {
2393
+ var el = wrapRef.current
2394
+ if (!el || !kg.width) return
2395
+ var w = el.clientWidth - 24
2396
+ var hgt = el.clientHeight - 24
2397
+ // 下限用 WBG_MIN_ZOOM(0.08)而非 0.35 —— 876 节点的概览视图本就是「缩小看结构」,
2398
+ // 用 0.35 当底会永远 fit 不到全貌(实测需 1.9%)。
2399
+ var z = Math.min(1.6, Math.max(WBG_MIN_ZOOM, Math.min(w / kg.width, hgt / kg.height)))
2400
+ setZoomBoth(z)
2401
+ setPanBoth({ x: 0, y: 0 })
2402
+ }
2403
+ var setPanBoth = function (p) { setPan(p); try { localStorage.setItem(WBG_PAN_KEY, JSON.stringify(p)) } catch (e) {} }
2404
+
2405
+ var onDown = function (e) {
2406
+ if (e.button !== 0) return
2407
+ dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false }
2408
+ e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId)
2409
+ }
2410
+ var onMove = function (e) {
2411
+ var d = dragRef.current
2412
+ if (!d) return
2413
+ var dx = e.clientX - d.sx, dy = e.clientY - d.sy
2414
+ if (Math.abs(dx) + Math.abs(dy) > 3) d.moved = true
2415
+ setPan({ x: d.px + dx, y: d.py + dy })
2416
+ }
2417
+ var onUp = function (e) {
2418
+ var d = dragRef.current
2419
+ dragRef.current = null
2420
+ if (d) { try { localStorage.setItem(WBG_PAN_KEY, JSON.stringify(pan)) } catch (e2) {} }
2421
+ e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId)
2422
+ }
2423
+ var onWheel = function (e) {
2424
+ if (!e.ctrlKey && !e.metaKey && Math.abs(e.deltaY) < 2) return
2425
+ e.preventDefault()
2426
+ setZoomBoth(zoom + (e.deltaY < 0 ? 0.08 : -0.08))
2427
+ }
2428
+
2429
+ // 空态/错误态: 与 KanbanView 同一套 reason 消费
2430
+ if (!data || !cards.length) {
2431
+ var reason = pay && pay.reason
2432
+ var msg = reason === 'legacy-mode'
2433
+ ? (zh ? '当前是旧版白板(legacy)档。切到新版看板: 设置 → 自动记忆引擎 → 看板模式 → 「新版看板(dsh-graph)」。'
2434
+ : 'Legacy board mode. Switch to graph board in Settings.')
2435
+ : reason === 'error'
2436
+ ? ((zh ? '加载出错: ' : 'Load error: ') + String((pay && pay.error) || ''))
2437
+ : (zh ? '画布暂无内容 —— 写一次白板或账本(memory_note)后这里会出现节点。'
2438
+ : 'No nodes yet — write a plan or ledger (memory_note) and nodes will appear.')
2439
+ return h('div', { 'data-dam-wbg-wrap': '', style: { padding: '18px' } }, [
2440
+ h('div', { 'data-dam-hint': '', key: 'm', style: { lineHeight: 1.7 } }, msg),
2441
+ h('button', { key: 'r', 'data-dam-btn': '', onClick: function () { bump(tick + 1) }, style: { marginTop: '10px' } }, zh ? '刷新' : 'reload'),
2442
+ ])
2443
+ }
2444
+
2445
+ var btn = function (label, onClick, title) {
2446
+ return h('button', { 'data-dam-btn': '', onClick: onClick, title: title || '', style: { padding: '1px 8px', fontSize: '11px' } }, label)
2447
+ }
2448
+ var bar = h('div', {
2449
+ 'data-dam-wbg-bar': '',
2450
+ style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', padding: '8px 10px', borderBottom: '1px solid color-mix(in srgb, currentColor 12%, transparent)' },
2451
+ }, [
2452
+ btn('A-', function () { setZoomBoth(zoom - 0.1) }),
2453
+ h('span', { key: 'z', 'data-dam-hint': '', style: { fontSize: '10.5px', minWidth: '38px', textAlign: 'center' } }, Math.round(zoom * 100) + '%'),
2454
+ btn('A+', function () { setZoomBoth(zoom + 0.1) }),
2455
+ h('span', { key: 'g1', style: { width: '6px' } }),
2456
+ btn(zh ? '适应' : 'fit', fit, zh ? '缩放到刚好装下' : 'fit to view'),
2457
+ h('span', { key: 'g2', style: { width: '6px' } }),
2458
+ h('input', {
2459
+ key: 'q', 'data-dam-input': '', value: q, placeholder: zh ? '搜索节点…' : 'search nodes…',
2460
+ onChange: function (e) { setQ(e.target.value) },
2461
+ style: { flex: '1 1 120px', minWidth: '100px', fontSize: '11px', padding: '2px 7px' },
2462
+ }),
2463
+ h('span', { key: 'st', 'data-dam-hint': '', style: { fontSize: '10.5px', opacity: .7 } },
2464
+ (zh ? '节点 ' : 'nodes ') + visibleNodes.length + '/' + kg.nodes.length + (zh ? ' · 列 ' : ' · cols ') + (kg.keys || []).length + (zh ? ' · 共 ' : ' · total ') + kg.totalCards),
2465
+ ])
2466
+
2467
+ // SVG 连线(在 transform 容器内, 与节点同一坐标系)
2468
+ var edgeEls = []
2469
+ for (var ei = 0; ei < kg.edges.length; ei++) {
2470
+ var ed = kg.edges[ei]
2471
+ var a = nodeById[ed.from], b = nodeById[ed.to]
2472
+ if (!a || !b || !visIds[a.id] || !visIds[b.id]) continue
2473
+ var x1 = a.x + WBG_NODE_W / 2, y1 = a.y + WBG_NODE_H
2474
+ var x2 = b.x + WBG_NODE_W / 2, y2 = b.y
2475
+ var my = (y1 + y2) / 2
2476
+ edgeEls.push(h('path', {
2477
+ key: 'e' + ei,
2478
+ d: 'M' + x1 + ' ' + y1 + ' C' + x1 + ' ' + my + ', ' + x2 + ' ' + my + ', ' + x2 + ' ' + y2,
2479
+ fill: 'none',
2480
+ stroke: 'color-mix(in srgb, ' + wbLaneTone(nodeById[ed.to].laneKey || '') + ' 46%, transparent)',
2481
+ 'stroke-width': 1.4,
2482
+ }))
2483
+ }
2484
+
2485
+ var nodeEls = []
2486
+ for (var nk = 0; nk < kg.nodes.length; nk++) {
2487
+ var nn = kg.nodes[nk]
2488
+ if (!visIds[nn.id]) continue
2489
+ nodeEls.push(h(WbgNode, { key: nn.id, node: nn, zoom: zoom, selected: sel === nn.id, onPick: function (id) { setSel(function (p) { return p === id ? null : id }) } }))
2490
+ }
2491
+
2492
+ // 列标题: 泳道名 + 「已显示/总数」+ 色条。
2493
+ // 为什么必须有: 默认缩放到 fit 时节点字会小到读不清, **列标题是唯一还能读的锚点**;
2494
+ // 人的看图顺序是「先认这 6 列是什么 → 再看某一列里有什么」, 没有列标题就只剩一堆色块。
2495
+ var LANE_LABEL_ZH = { goal: '目标', state: '进行中', deadend: '失败与弯路', progress: '进度与下一步', archive: '版本归档', misc: '其它' }
2496
+ var groupEls = []
2497
+ for (var gi = 0; gi < (kg.groups || []).length; gi++) {
2498
+ var g = kg.groups[gi]
2499
+ var tone = wbLaneTone(g.key)
2500
+ var laneName = zh ? (LANE_LABEL_ZH[g.key] || g.key) : g.key
2501
+ groupEls.push(h('div', {
2502
+ key: 'gh' + g.key, 'data-dam-wbg-colhead': '',
2503
+ style: {
2504
+ position: 'absolute', left: g.x + 'px', top: WBG_PAD + 'px', width: WBG_NODE_W + 'px',
2505
+ boxSizing: 'border-box', paddingBottom: '6px',
2506
+ borderBottom: '2px solid color-mix(in srgb, ' + tone + ' 62%, transparent)',
2507
+ },
2508
+ }, [
2509
+ h('div', {
2510
+ key: 'n', style: {
2511
+ fontSize: (13.5 / Math.max(zoom, 0.4)) + 'px', fontWeight: 700, color: tone,
2512
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
2513
+ },
2514
+ }, laneName),
2515
+ h('div', { key: 'c', 'data-dam-hint': '', style: { fontSize: (10.5 / Math.max(zoom, 0.4)) + 'px', opacity: .7, marginTop: '1px' } },
2516
+ (g.shownN + '/' + g.total) + ' ' + (zh ? '条' : '')),
2517
+ ]))
2518
+ }
2519
+
2520
+ return h('div', { 'data-dam-wbg-wrap': '', style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } }, [
2521
+ bar,
2522
+ h('div', { key: 'body', style: { display: 'flex', flex: '1 1 auto', minHeight: 0 } }, [
2523
+ h('div', {
2524
+ key: 'canvas', ref: wrapRef,
2525
+ 'data-dam-wbg-canvas': '',
2526
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp,
2527
+ onWheel: onWheel,
2528
+ onClick: function () { setSel(null) },
2529
+ style: {
2530
+ position: 'relative', flex: '1 1 auto', minWidth: 0, overflow: 'hidden',
2531
+ cursor: dragRef.current ? 'grabbing' : 'grab',
2532
+ backgroundImage: 'radial-gradient(color-mix(in srgb, currentColor 9%, transparent) 1px, transparent 1px)',
2533
+ backgroundSize: '22px 22px',
2534
+ },
2535
+ }, h('div', {
2536
+ 'data-dam-wbg-stage': '',
2537
+ style: {
2538
+ position: 'absolute', left: 0, top: 0,
2539
+ width: kg.width + 'px', height: kg.height + 'px',
2540
+ transform: 'translate(' + pan.x + 'px,' + pan.y + 'px) scale(' + zoom + ')',
2541
+ transformOrigin: '0 0',
2542
+ willChange: 'transform',
2543
+ },
2544
+ }, [
2545
+ h('svg', { key: 'svg', width: kg.width, height: kg.height, style: { position: 'absolute', left: 0, top: 0, pointerEvents: 'none' } }, edgeEls),
2546
+ ].concat(groupEls).concat(nodeEls))),
2547
+ // 侧栏: 选中节点的完整信息(宽屏才放得下全文 —— 这才是画布相对泳道的增量价值)
2548
+ selNode ? h('div', {
2549
+ key: 'side', 'data-dam-wbg-side': '',
2550
+ style: {
2551
+ flex: '0 0 320px', maxWidth: '44%', overflow: 'auto', padding: '12px 14px',
2552
+ borderLeft: '1px solid color-mix(in srgb, currentColor 12%, transparent)',
2553
+ },
2554
+ }, [
2555
+ h('div', { key: 'x', style: { display: 'flex', alignItems: 'flex-start', gap: '8px' } }, [
2556
+ h('div', { key: 'h', style: { flex: 1, fontWeight: 700, fontSize: '14px', lineHeight: 1.4 } }, selNode.title),
2557
+ h('button', { key: 'c', 'data-dam-btn': '', onClick: function () { setSel(null) } }, '✕'),
2558
+ ]),
2559
+ selNode.kicker ? h('div', { key: 'k', 'data-dam-hint': '', style: { marginTop: '4px', fontSize: '11px', opacity: .75 } }, selNode.kicker + (selNode.anchored ? ' · ⚓ ' + (zh ? '有锚点' : 'anchored') : '')) : null,
2560
+ selNode.docTitle ? h('div', { key: 'd', 'data-dam-hint': '', style: { marginTop: '2px', fontSize: '11px', opacity: .6, wordBreak: 'break-all' } }, selNode.docTitle) : null,
2561
+ selNode.body ? h('div', { key: 'b', style: { marginTop: '12px', fontSize: '12px', lineHeight: 1.72, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, selNode.body) : h('div', { key: 'b2', 'data-dam-hint': '', style: { marginTop: '12px', fontSize: '11.5px', opacity: .6 } }, zh ? '(这一节没有更多正文)' : '(no further body)'),
2562
+ ]) : null,
2563
+ ]),
2564
+ ])
2565
+ }
2566
+
1355
2567
  function Loading(props) {
1356
2568
  var label = props && props.label ? props.label : t('loading')
1357
2569
  return h('div', { 'data-dam-loading': '' }, h('span', { 'data-dam-spinner': '', 'aria-hidden': 'true' }), h('span', null, label))
@@ -1745,7 +2957,7 @@ window.__ModuleLoader__.load({
1745
2957
  }
1746
2958
  // 美术规格对齐 artifacts/m7-live-pre/ui-assets/semantic-tier-ui.html 组件③
1747
2959
  var card = { border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(255,255,255,.16)) 55%, transparent)', borderRadius: '12px', padding: '10px 12px', marginBottom: '9px', background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-1, rgba(128,128,128,.08)) 45%, transparent)', boxShadow: '0 4px 14px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.14)' }
1748
- var badge = function (txt, bg, fg) { return h('span', { style: { fontSize: 'calc(10.5px * var(--dam-scale))', fontWeight: 700, padding: '2px 8px', borderRadius: '6px', background: bg, color: fg || 'var(--dsw-alias-text-primary, inherit)', letterSpacing: '.02em' } }, txt) }
2960
+ var badge = function (txt, bg, fg) { return h('span', { style: { fontSize: 'calc(10.5px * var(--dam-scale))', fontWeight: 700, padding: '2px 8px', borderRadius: '6px', background: bg, color: fg || 'var(--dsw-alias-label-primary, inherit)', letterSpacing: '.02em' } }, txt) }
1749
2961
  var decBg = { emit: ['rgba(47,164,106,.24)', '#7fdcb0'], prefetch: ['rgba(196,138,42,.2)', '#e8c584'], suppress: ['rgba(128,128,128,.16)', 'rgba(255,255,255,.65)'] }
1750
2962
  var laneBg = { explicit: ['rgba(111,155,255,.24)', '#b9ceff'], proactive: ['rgba(160,120,255,.22)', '#d4c2ff'] }
1751
2963
  var reasonChip = function (txt) { return h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 'calc(10px * var(--dam-scale))', padding: '2px 7px', borderRadius: '5px', background: 'rgba(255,214,150,.1)', color: '#ffd9a1' } }, txt) }
@@ -1783,7 +2995,7 @@ window.__ModuleLoader__.load({
1783
2995
  index[k].push(row)
1784
2996
  })
1785
2997
  var card = { border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(255,255,255,.16)) 55%, transparent)', borderRadius: '12px', padding: '10px 12px', marginBottom: '9px', background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-1, rgba(128,128,128,.08)) 45%, transparent)', boxShadow: '0 4px 14px rgba(0,0,0,.12), inset 0 1px 0 rgba(255,255,255,.14)' }
1786
- var badge = function (txt, bg, fg) { return h('span', { style: { fontSize: 'calc(10.5px * var(--dam-scale))', fontWeight: 700, padding: '2px 8px', borderRadius: '6px', background: bg, color: fg || 'var(--dsw-alias-text-primary, inherit)', letterSpacing: '.02em' } }, txt) }
2998
+ var badge = function (txt, bg, fg) { return h('span', { style: { fontSize: 'calc(10.5px * var(--dam-scale))', fontWeight: 700, padding: '2px 8px', borderRadius: '6px', background: bg, color: fg || 'var(--dsw-alias-label-primary, inherit)', letterSpacing: '.02em' } }, txt) }
1787
2999
  var decBg = { emit: ['rgba(47,164,106,.24)', '#7fdcb0'], prefetch: ['rgba(196,138,42,.2)', '#e8c584'], suppress: ['rgba(128,128,128,.16)', 'rgba(255,255,255,.65)'] }
1788
3000
  var laneBg = { explicit: ['rgba(111,155,255,.24)', '#b9ceff'], proactive: ['rgba(160,120,255,.22)', '#d4c2ff'] }
1789
3001
  var reasonChip = function (txt) { return h('span', { style: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 'calc(10px * var(--dam-scale))', padding: '2px 7px', borderRadius: '5px', background: 'rgba(255,214,150,.1)', color: '#ffd9a1' } }, txt) }
@@ -1883,11 +3095,145 @@ window.__ModuleLoader__.load({
1883
3095
  rows.push(h(Card, { title: 'MEMORY.md (' + (locale === 'zh' ? '用户级' : 'user-level') + ')' },
1884
3096
  h('div', { 'data-dam-content': '' }, userText || t('userMemoryEmpty')),
1885
3097
  userTruncated ? h('div', { 'data-dam-hint': '' }, t('userMemoryTruncated')) : null))
3098
+ // ★R7:硬性约束编辑面板(用户要求「能自行增删改」)
3099
+ // ★2026-09-20 崩溃修复:本处原写 `{ nonce: nonce }`,但 **LogsTab 不接收任何 props**
3100
+ // (`function LogsTab()`,调用点 `h(LogsTab)` 未传参),而 `nonce` 只在 MemoryPanel
3101
+ // 作用域内定义(L3969-3970)⇒ 打开日志页签时求值 nonce 抛
3102
+ // ReferenceError: nonce is not defined,整个窗格崩溃。
3103
+ // RulesEditPanel 本就把它当可选(`props && props.nonce ? … : 0`),故传空对象即可。
3104
+ rows.push(h(RulesEditPanel, {}))
1886
3105
  }
1887
3106
  return h('div', null, rows)
1888
3107
  }
1889
3108
  function pathName(p) { var parts = String(p).split(/[\\/]/); return parts[parts.length - 1] }
1890
3109
 
3110
+ // ── ★R1–R6(2026-09-20 用户「看不懂只能盲确认」)审批面人话化辅助 ────────
3111
+ // R3:stage 枚举 → 中文。
3112
+ function stageLabel(stage) {
3113
+ var m = { observed: 'hubStageObserved', candidate: 'hubStageCandidate', validated: 'hubStageValidated', active: 'hubStageActive', deprecated: 'hubStageDeprecated' }
3114
+ return m[stage] ? t(m[stage]) : String(stage || '')
3115
+ }
3116
+ // R2:reasonCodes → 人话。**为什么不能晋升**必须看得见。
3117
+ // 后端只回传机器码(如 diversity-below-3),这里翻成具体数字与下一步动作。
3118
+ function whyNotPromotable(promotion) {
3119
+ if (!promotion) return ''
3120
+ var d = promotion.detail || {}
3121
+ var codes = promotion.reasonCodes || []
3122
+ for (var i = 0; i < codes.length; i++) {
3123
+ var c = codes[i]
3124
+ if (c === 'observation-only') return t('hubWhyObservationOnly')
3125
+ if (c === 'deprecated') return t('hubStageDeprecated')
3126
+ if (c === 'no-success-criteria') return t('hubWhyNoCriteria')
3127
+ if (c === 'has-correction') return t('hubWhyHasCorrection')(d.corrections || 0)
3128
+ if (c.indexOf('high-risk-awaiting-approval') === 0) return t('hubWhyHighRisk')
3129
+ if (c.indexOf('diversity-below-') === 0) return t('hubWhyDiversity')(d.diversity != null ? d.diversity : 0, d.need != null ? d.need : 0)
3130
+ if (c.indexOf('success-below-') === 0) return t('hubWhySuccess')(d.successCount != null ? d.successCount : 0, d.need != null ? d.need : 0)
3131
+ if (c.indexOf('correction-rate-') === 0) return t('hubWhyCorrectionRate')(String(c).replace('correction-rate-', ''), d.cap != null ? d.cap : '')
3132
+ if (c === 'model-authorized') continue
3133
+ }
3134
+ if (promotion.decision === 'promote') return t('hubWhyCanPromote')
3135
+ return codes.length ? codes.join(' · ') : ''
3136
+ }
3137
+
3138
+ // ── ★R7(2026-09-20 用户要求「用户级硬性约束须能自行增删改」)─────────────
3139
+ // 这一段规则**每轮无条件注入、不走语义层** ⇒ 过时条目不会被自动淘汰、
3140
+ // AI(memory_user)也可能写错 ⇒ 必须让用户能自己改。
3141
+ // 接口与将来换风格无关:本面板只用 data-dam-* 语义属性,样式由外层决定。
3142
+ function RulesEditPanel(props) {
3143
+ var nonce = props && props.nonce ? props.nonce : 0
3144
+ var dataPair = useState(null); var data = dataPair[0]; var setData = dataPair[1]
3145
+ var errPair = useState(''); var err = errPair[0]; var setErr = errPair[1]
3146
+ var busyPair = useState(false); var busy = busyPair[0]; var setBusy = busyPair[1]
3147
+ var msgPair = useState(''); var msg = msgPair[0]; var setMsg = msgPair[1]
3148
+ var draftPair = useState({}); var draft = draftPair[0]; var setDraft = draftPair[1]
3149
+ var newPair = useState(''); var newText = newPair[0]; var setNewText = newPair[1]
3150
+ var tickPair = useState(0); var tick = tickPair[0]; var setTick = tickPair[1]
3151
+ useEffect(function () {
3152
+ var alive = true
3153
+ apiGet(API.rulesList, {}).then(function (r) {
3154
+ if (!alive) return
3155
+ if (r && r.error) { setErr(r.error) } else { setData(r); setErr('') }
3156
+ }).catch(function (e) { if (alive) setErr(String(e && e.message ? e.message : e)) })
3157
+ return function () { alive = false }
3158
+ }, [nonce, tick])
3159
+ function apply(op, payload) {
3160
+ setBusy(true); setMsg('')
3161
+ apiPost(API.rulesApply, Object.assign({ op: op }, payload || {})).then(function (r) {
3162
+ setBusy(false)
3163
+ if (r && r.error) { setMsg('❌ ' + r.error); return }
3164
+ // ★即时回显(用户既有硬性偏好):直接用餐后端返回的新列表,不等重新拉整页
3165
+ setData({ path: data && data.path, items: r.items, preview: r.preview })
3166
+ setMsg('✅ ' + (r.result || 'ok'))
3167
+ setDraft({})
3168
+ }).catch(function (e) { setBusy(false); setMsg('❌ ' + String(e && e.message ? e.message : e)) })
3169
+ }
3170
+ if (err) return h(Card, { title: t('rulesTitle') }, h('div', { 'data-dam-hint': '' }, err))
3171
+ if (!data) return h(Card, { title: t('rulesTitle') }, h('div', { 'data-dam-hint': '' }, '…'))
3172
+ var items = data.items || []
3173
+ var rows = []
3174
+ rows.push(h('div', { 'data-dam-hint': '' }, t('rulesIntro')))
3175
+ rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } }, t('rulesPath') + (data.path || '')))
3176
+ if (msg) rows.push(h('div', { key: 'msg', 'data-dam-hint': '', style: { marginTop: '6px' } }, msg))
3177
+ if (!items.length) rows.push(h('div', { key: 'empty', 'data-dam-hint': '', style: { marginTop: '8px' } }, t('rulesEmpty')))
3178
+ for (var i = 0; i < items.length; i++) {
3179
+ (function (it, idx) {
3180
+ var editing = draft[idx] != null
3181
+ var srcTag = it.source === 'ai' ? t('rulesSourceAi') : t('rulesSourceUser')
3182
+ var head = h('div', { 'data-dam-hint': '', style: { fontSize: '12px', opacity: .75 } },
3183
+ '#' + (idx + 1) + ' · ' + srcTag + (it.dateSection ? ' · ' + it.dateSection : ''))
3184
+ var bodyEl
3185
+ if (editing) {
3186
+ bodyEl = h('div', null,
3187
+ h('textarea', {
3188
+ 'data-dam-input': '',
3189
+ value: draft[idx],
3190
+ rows: 3,
3191
+ style: { width: '100%', boxSizing: 'border-box' },
3192
+ onInput: function (e) { var d = Object.assign({}, draft); d[idx] = e.target.value; setDraft(d) },
3193
+ }),
3194
+ h('button', { 'data-dam-btn': '', disabled: busy, onClick: function () { apply('update', { index: idx, text: draft[idx] }) } }, t('rulesSave')),
3195
+ ' ',
3196
+ h('button', { 'data-dam-btn': '', onClick: function () { var d = Object.assign({}, draft); delete d[idx]; setDraft(d) } }, t('rulesCancel')))
3197
+ } else {
3198
+ bodyEl = h('div', null,
3199
+ h('div', { 'data-dam-content': '' }, it.text),
3200
+ h('button', { 'data-dam-btn': '', onClick: function () { var d = Object.assign({}, draft); d[idx] = it.text; setDraft(d) } }, t('rulesEdit')),
3201
+ ' ',
3202
+ h('button', {
3203
+ 'data-dam-btn': '',
3204
+ disabled: busy,
3205
+ onClick: function () {
3206
+ // ★R7-4:该层渲染器(renderRulesSectionPre)**不认任何状态标记** ⇒
3207
+ // 软删标记会被当正文注入模型。故删除**真删**且不可撤销 ⇒ 二次确认。
3208
+ if (typeof window !== 'undefined' && !window.confirm(t('rulesConfirmDelete'))) return
3209
+ apply('remove', { index: idx })
3210
+ },
3211
+ }, t('rulesDelete')))
3212
+ }
3213
+ rows.push(h('div', { key: 'r' + idx, 'data-dam-rule': '', style: { marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(128,128,128,.25)' } }, head, bodyEl))
3214
+ })(items[i], i)
3215
+ }
3216
+ rows.push(h('div', { key: 'add', style: { marginTop: '12px' } },
3217
+ h('div', { 'data-dam-hint': '' }, t('rulesAddHint')),
3218
+ h('textarea', {
3219
+ 'data-dam-input': '',
3220
+ value: newText,
3221
+ rows: 2,
3222
+ placeholder: t('rulesAddPlaceholder'),
3223
+ style: { width: '100%', boxSizing: 'border-box' },
3224
+ onInput: function (e) { setNewText(e.target.value) },
3225
+ }),
3226
+ h('button', {
3227
+ 'data-dam-btn': '',
3228
+ disabled: busy || !String(newText || '').trim(),
3229
+ onClick: function () { apply('add', { text: newText }); setNewText('') },
3230
+ }, t('rulesAdd'))))
3231
+ // ★R7-6:预览「下一轮注入会变成什么样」
3232
+ rows.push(h('div', { key: 'pvh', 'data-dam-hint': '', style: { marginTop: '12px' } }, t('rulesPreviewHint')))
3233
+ rows.push(h('pre', { key: 'pv', 'data-dam-content': '', style: { whiteSpace: 'pre-wrap' } }, data.preview || ''))
3234
+ return h(Card, { title: t('rulesTitle') }, h('div', null, rows))
3235
+ }
3236
+
1891
3237
  // M8 记忆中枢页签:展示三层记忆(经历/事实/技能)概览。数据源=/memory-hub 只读投影。
1892
3238
  function MemoryHubTab(props) {
1893
3239
  var nonce = props && props.nonce ? props.nonce : 0
@@ -1918,7 +3264,9 @@ window.__ModuleLoader__.load({
1918
3264
  body: JSON.stringify({ action: action, procedureId: procedureId, v: v }) })
1919
3265
  .then(function (r) { return r.json() })
1920
3266
  .then(function (j) {
1921
- setActMsg(action + ': ' + (j && (j.decision || j.reason || (j.ok === false ? (j.reason || 'rejected') : 'ok')) || 'done'))
3267
+ // issue #30:附带 reasonCodes —— 否则"点了晋升但没晋级"只显示 keep,看不出原因
3268
+ // (如 observation-only / diversity-below-3 / no-success-criteria)。
3269
+ setActMsg(action + ': ' + (j && (j.decision || j.reason || (j.ok === false ? (j.reason || 'rejected') : 'ok')) || 'done') + (j && Array.isArray(j.reasonCodes) && j.reasonCodes.length ? ' (' + j.reasonCodes.join(', ') + ')' : ''))
1922
3270
  setRefresh(function (x) { return x + 1 })
1923
3271
  })
1924
3272
  .catch(function (e) { setActMsg(action + ' failed: ' + String(e && e.message)) })
@@ -1951,16 +3299,41 @@ window.__ModuleLoader__.load({
1951
3299
  pipeline.map(function (p) {
1952
3300
  var ev = p.evidence || {}
1953
3301
  return h('div', { 'data-dam-content': '', key: p.procedureId, style: { padding: '5px 0', borderBottom: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(255,255,255,.16)) 40%, transparent)' } },
1954
- h('div', null, h('b', null, p.title), h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, '[' + p.stage + ']' + (p.pinned ? ' 📌' : '') + (p.riskLevel === 'high' ? ' ⚠' : '')),
1955
- h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, (locale === 'zh' ? '成功 ' : 'succ ') + (ev.success || 0) + ' · ' + (locale === 'zh' ? '会话 ' : 'sess ') + (ev.sessions || 0))),
3302
+ // ★R1:技能名 + 可读标注;★R3:stage 给中文
3303
+ h('div', null, h('b', null, p.title), h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } },
3304
+ '[' + stageLabel(p.stage) + ']'
3305
+ + (p.observationOnly ? (locale === 'zh' ? ' · 观察(不参与自动晋升)' : ' · observation-only') : '')
3306
+ + (p.pinned ? ' 📌' : '') + (p.riskLevel === 'high' ? ' ⚠' : '')),
3307
+ // ★R5:evidence 给人话(不是裸数字)
3308
+ h('span', { 'data-dam-hint': '', style: { marginLeft: '6px' } }, t('hubEvLine')(ev))),
3309
+ // ★R2:**为什么不能晋升** —— 必须在界面上说清楚
3310
+ (function () {
3311
+ var why = whyNotPromotable(p.promotion)
3312
+ if (!why) return null
3313
+ var ok = p.promotion && p.promotion.decision === 'promote'
3314
+ return h('div', { 'data-dam-hint': '', style: { marginTop: '4px', color: ok ? 'inherit' : 'var(--dsw-alias-text-warning, #d90)', opacity: .95 } },
3315
+ h('b', null, t('hubWhyTitle')), why)
3316
+ })(),
3317
+ // ★R4:预览「晋升后会注入什么」(真实 steps / successCriteria)
3318
+ ((p.steps && p.steps.length) || (p.successCriteria && p.successCriteria.length))
3319
+ ? h('details', { style: { marginTop: '4px' } },
3320
+ h('summary', { 'data-dam-hint': '', style: { cursor: 'pointer' } }, t('hubInjectPreview')),
3321
+ h('div', { 'data-dam-content': '', style: { marginTop: '4px', paddingLeft: '10px' } },
3322
+ (p.steps || []).map(function (s, si) { return h('div', { key: 's' + si }, (si + 1) + '. ' + s) }),
3323
+ (p.successCriteria && p.successCriteria.length)
3324
+ ? h('div', { style: { marginTop: '4px' } }, h('b', null, t('hubInjectCriteria')), (p.successCriteria || []).join(' / '))
3325
+ : null))
3326
+ : h('div', { 'data-dam-hint': '', style: { marginTop: '4px', opacity: .7 } }, t('hubNoSteps')),
1956
3327
  h('div', { style: { display: 'flex', gap: '6px', marginTop: '5px' } },
1957
- h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px' }, onClick: function () { hubAct('promote', p.procedureId) } }, locale === 'zh' ? '晋升' : 'promote'),
1958
- h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px' }, onClick: function () { hubAct('activate', p.procedureId) } }, locale === 'zh' ? '直接激活' : 'activate'),
3328
+ // issue #30:纯 episode 观察行**结构上**不可能晋升(无 successCriteria),
3329
+ // 继续显示"晋升"按钮只会让人反复点击却看不到变化 ⇒ 直接隐藏并打观察标。
3330
+ !p.observationOnly && h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px' }, onClick: function () { hubAct('promote', p.procedureId) } }, locale === 'zh' ? '晋升' : 'promote'),
3331
+ !p.observationOnly && p.stage === 'validated' && h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px' }, onClick: function () { hubAct('activate', p.procedureId) } }, locale === 'zh' ? '直接激活' : 'activate'),
1959
3332
  h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px', opacity: .75 }, onClick: function () { hubAct('deprecate', p.procedureId) } }, locale === 'zh' ? '弃用' : 'deprecate'),
1960
3333
  h('button', { 'data-dam-btn': '', style: { fontSize: 'calc(10.5px * var(--dam-scale))', padding: '3px 10px', opacity: .75 }, onClick: function () { hubAct('pin', p.procedureId, !p.pinned) } }, p.pinned ? (locale === 'zh' ? '取消置顶' : 'unpin') : (locale === 'zh' ? '置顶' : 'pin'))))
1961
3334
  })))
1962
3335
  }
1963
- if (actMsg) rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-warn, #e8c584)' } }, actMsg))
3336
+ if (actMsg) rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-state-warn-primary, #e8c584)' } }, actMsg))
1964
3337
  // 事实层
1965
3338
  rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '12px' } }, t('hubFacts')))
1966
3339
  var factList = (facts && facts.recent) || []
@@ -1969,7 +3342,7 @@ window.__ModuleLoader__.load({
1969
3342
  return h('div', { 'data-dam-content': '', key: f.factId, style: { padding: '3px 0' } }, f.subject + ' · ' + f.predicate + (f.object ? ' · ' + f.object : ''))
1970
3343
  }) : h('div', { 'data-dam-content': '' }, t('hubFactsEmpty'))))
1971
3344
  if (facts && facts.pendingConflicts && facts.pendingConflicts.length) {
1972
- rows.push(h('div', { 'data-dam-hint': '', style: { color: 'var(--dsw-alias-warn, #e8c584)' } }, t('hubConflicts') + ': ' + facts.pendingConflicts.length))
3345
+ rows.push(h('div', { 'data-dam-hint': '', style: { color: 'var(--dsw-alias-state-warn-primary, #e8c584)' } }, t('hubConflicts') + ': ' + facts.pendingConflicts.length))
1973
3346
  }
1974
3347
  // 经历层
1975
3348
  rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '12px' } }, t('hubEpisodic')))
@@ -2036,7 +3409,7 @@ window.__ModuleLoader__.load({
2036
3409
  var counts = data.counts || { total: 0, ok: 0, stale: 0, unrepairable: 0 }
2037
3410
  var rows = []
2038
3411
  if (data.indexEnabled === false) {
2039
- rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-warn, #e8c584)' } },
3412
+ rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-state-warn-primary, #e8c584)' } },
2040
3413
  locale === 'zh' ? '⚠ 记忆锚定索引未启用:语料健康不做 sidecar 比对(无 stale 可修)。如需「修复 stale / 删除记忆」,请到 设置 → 自动记忆引擎 开启「记忆锚定索引」。此提示不影响记忆读写与检索。' : '⚠ Memory anchor index disabled: corpus health performs no sidecar comparison (nothing stale). To use repair/delete, enable "Memory anchor index" under Settings → Semantic engine. Read/write/recall are unaffected.'))
2041
3414
  }
2042
3415
  rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '8px' } }, t('storageScanHint')))
@@ -2065,7 +3438,7 @@ window.__ModuleLoader__.load({
2065
3438
  if (!(delFile && delId)) return
2066
3439
  act('delete', { filePath: delFile, memoryId: delId }, function () { setDelId('') })
2067
3440
  } }, locale === 'zh' ? '删除' : 'delete')))))
2068
- if (msg) rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-warn, #e8c584)' } }, msg))
3441
+ if (msg) rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', color: 'var(--dsw-alias-state-warn-primary, #e8c584)' } }, msg))
2069
3442
  var audit = (data.audit || []).slice(-4)
2070
3443
  if (audit.length) {
2071
3444
  rows.push(h('div', { 'data-dam-hint': '', style: { marginTop: '12px', opacity: .7 } },
@@ -2165,7 +3538,7 @@ window.__ModuleLoader__.load({
2165
3538
  var t0 = Date.now()
2166
3539
  var timeoutMs = 90000
2167
3540
  try {
2168
- var cfg = await apiGet(API.config)
3541
+ var cfg = configOf(await apiGet(API.config))
2169
3542
  var sec = cfg && Number(cfg.autoContinueRefreshTimeoutSeconds)
2170
3543
  if (sec >= 15 && sec <= 600) timeoutMs = sec * 1000
2171
3544
  } catch (eC) {}
@@ -2236,7 +3609,7 @@ window.__ModuleLoader__.load({
2236
3609
  autoState.suppressUntil = Date.now() + 5 * 60 * 1000
2237
3610
  try {
2238
3611
  var cfg = null
2239
- try { cfg = await apiGet(API.config) } catch (eCfg) {}
3612
+ try { cfg = configOf(await apiGet(API.config)) } catch (eCfg) {}
2240
3613
  var ritual = !(cfg && cfg.autoContinueRefreshRitual === false) && !(opts && opts.skipRefresh)
2241
3614
  if (ritual) { try { await refreshOldSession(onMsg) } catch (eRf) {} }
2242
3615
  onMsg(locale === 'zh' ? '正在构造交接材料(含旧会话转写)…' : 'Preparing handoff material (incl. prev-session transcript)…')
@@ -2260,6 +3633,10 @@ window.__ModuleLoader__.load({
2260
3633
  var openPair = useState(null)
2261
3634
  var open = openPair[0]
2262
3635
  var setOpen = openPair[1]
3636
+ // 白板看板数据(2026-09-16 兼并 dsh-graph): graph 档有值 → 渲染列式泳道; legacy 档恒 null。
3637
+ var kbDataPair = useState(null)
3638
+ var kbData = kbDataPair[0]
3639
+ var setKbData = kbDataPair[1]
2263
3640
  var contentPair = useState('')
2264
3641
  var content = contentPair[0]
2265
3642
  var setContent = contentPair[1]
@@ -2271,16 +3648,46 @@ window.__ModuleLoader__.load({
2271
3648
  var contMsg = contMsgPair[0]
2272
3649
  var setContMsg = contMsgPair[1]
2273
3650
  // M-CM6-C 自动接续(①轮次边界触发 + ②确认卡由全局 AutoContinueHost 执行;这里只读/写开关与阈值)
3651
+ // 2026-09-14 解耦:白板页也读写这两个开关,且与设置页共用**同一对配置键**
3652
+ // (autoContinueEnabled / handoffEnabled)——两处不可能各说各话。
3653
+ // 注:两个键出厂默认均为 false(功能仍在测试期),默认关、界面可开。
2274
3654
  var autoPair = useState(null)
2275
3655
  var autoCfg = autoPair[0]
2276
3656
  var setAutoCfg = autoPair[1]
3657
+ var hbPair = useState(false)
3658
+ var handoffOn = hbPair[0]
3659
+ var setHandoffOn = hbPair[1]
3660
+ // WB-GRAPH boardMode 本地镜像(2026-09-16): 接续面板按钮即时翻面用; 初值从配置取, 保存成功后本地翻面。
3661
+ var boardModePair = useState('legacy')
3662
+ var boardModeLocal = boardModePair[0]
3663
+ var setBoardModeLocal = boardModePair[1]
3664
+ // 白板开关改动后必须重取白板数据(host 的 handoffPanelData 按 handoffEnabled 决定 enabled),
3665
+ // 否则「刚打开白板」页面上仍是旧的 enabled:false。
3666
+ var reloadPair = useState(0)
3667
+ var reloadKey = reloadPair[0]
3668
+ var setReload = reloadPair[1]
2277
3669
  useEffect(function () {
2278
- apiGet(API.config).then(function (c) {
2279
- setAutoCfg({ enabled: !(c && c.autoContinueEnabled === false), threshold: (c && Number(c.autoContinueThreshold)) || 0.75 })
3670
+ apiGet(API.config).then(function (d) {
3671
+ // 解包外壳(见 configOf):旧实现直读 `c.autoContinueEnabled`,d 其实是 `{ config, path }`
3672
+ // ⇒ 两个开关**显示恒为「开」**,且点击只会写 false(唯一可写方向是「关」)。
3673
+ var c = configOf(d)
3674
+ setAutoCfg({ enabled: !(c.autoContinueEnabled === false), threshold: Number(c.autoContinueThreshold) || 0.75 })
3675
+ setHandoffOn(!(c.handoffEnabled === false))
3676
+ setBoardModeLocal(c.boardMode === 'graph' ? 'graph' : 'legacy')
2280
3677
  }).catch(function () { setAutoCfg({ enabled: false, threshold: 0.7 }) })
2281
3678
  }, [])
3679
+ // 2026-09-14 修复:patch 里是**配置键**(autoContinueEnabled / autoContinueThreshold),而本地状态用的是
3680
+ // enabled / threshold。旧实现直接把 patch 并进本地状态 ⇒ 写的是 autoCfg.autoContinueEnabled,而按钮读的是
3681
+ // autoCfg.enabled ⇒ **按钮永远不回弹**:点了看似没反应,再点还是按同一个旧值计算(用户观感「自动接续无法开关」)。
3682
+ // 阈值输入同病:输入框读 autoCfg.threshold,旧实现写 autoCfg.autoContinueThreshold ⇒ 显示也不更新。
3683
+ // 注意:写入配置本身一直是成功的(宿主端单键合并),坏的只是本地回显 —— 所以"看起来没开关"与"其实已写盘"并存。
2282
3684
  var autoSave = function (patch) {
2283
- setAutoCfg(function (p) { return Object.assign({}, p, patch) })
3685
+ setAutoCfg(function (p) {
3686
+ var next = Object.assign({}, p)
3687
+ if (patch.autoContinueEnabled !== undefined) next.enabled = patch.autoContinueEnabled
3688
+ if (patch.autoContinueThreshold !== undefined) next.threshold = patch.autoContinueThreshold
3689
+ return next
3690
+ })
2284
3691
  saveConfigPatch(patch)
2285
3692
  }
2286
3693
  async function oneClickContinue() {
@@ -2298,8 +3705,14 @@ window.__ModuleLoader__.load({
2298
3705
  var alive = true
2299
3706
  setData(null)
2300
3707
  apiGet(API.handoffState, { sessionId: sid }).then(function (d) { if (alive) setData(d) }).catch(function () {})
3708
+ // 白板看板(2026-09-16 兼并 dsh-graph): 与白板数据同源同批拉取。
3709
+ // legacy 档宿主返回 { enabled:false } ⇒ 保持 kbData=null, 看板块不渲染(逐字节不变)。
3710
+ apiGet(API.kanbanBoard, { sessionId: sid }).then(function (k) {
3711
+ if (!alive) return
3712
+ setKbData(k && k.enabled ? k : null)
3713
+ }).catch(function () { if (alive) setKbData(null) })
2301
3714
  return function () { alive = false }
2302
- }, [sid])
3715
+ }, [sid, reloadKey])
2303
3716
  // 切换会话即刷新水位卡:订阅官方会话列表快照,current 变化时重取(2026-09-08)
2304
3717
  useEffect(function () {
2305
3718
  var unsub = null
@@ -2332,7 +3745,39 @@ window.__ModuleLoader__.load({
2332
3745
  return name
2333
3746
  }
2334
3747
  if (!data) return h(Loading)
2335
- if (!data.enabled) return h(Card, { title: t('planTitle') }, h('div', { 'data-dam-hint': '' }, t('planDisabled')))
3748
+ // 开关卡(2026-09-14 解耦):**先渲染开关,再谈白板内容** —— 旧实现在 !data.enabled 处直接 return,
3749
+ // 而白板开关默认关 ⇒ 白板页连开关都看不到(默认关 + 无处可开 = 死锁)。
3750
+ // 两个按钮与设置页共用同一对配置键,写盘路径也是既有机制(saveConfigPatch / autoSave)。
3751
+ var switchCard = h(Card, { title: t('handoffSwitchTitle') }, h('div', { 'data-dam-content': '' }, [
3752
+ h('div', { 'data-dam-hint': '', style: { marginBottom: '6px' } }, t('handoffSwitchHint')),
3753
+ h('div', { 'data-dam-row': '' }, [
3754
+ h('button', { 'data-dam-btn': '', 'data-dam-key': 'handoffEnabled', onClick: function () { var v = !handoffOn; setHandoffOn(v); saveConfigPatch({ handoffEnabled: v }, { onSaved: function () { setReload(function (n) { return n + 1 }) } }) } }, handoffOn ? t('handoffOn') : t('handoffOff')),
3755
+ // WB-GRAPH 白板模式快捷切换(接续面板, 2026-09-16): 与设置页同一配置键 boardMode, 点击翻面+提示需重启。
3756
+ // 用 reloadKey 作重渲染源(切换成功后 force reload), 避免引入新的状态耦合。
3757
+ h('button', { 'data-dam-btn': '', 'data-dam-key': 'boardMode', key: 'bm-' + reloadKey, style: { marginLeft: '8px' }, onClick: function () {
3758
+ var next = boardModeLocal === 'graph' ? 'legacy' : 'graph'
3759
+ setBoardModeLocal(next)
3760
+ saveConfigPatch({ boardMode: next }, { onSaved: function () { setReload(function (n) { return n + 1 }); window.alert(locale === 'zh' ? (next === 'graph' ? '已切到新版看板(dsh-graph)。重启 dsh web 后生效。' : '已切回旧版白板。重启 dsh web 后生效。') : (next === 'graph' ? 'Switched to graph board. Restart dsh web to take effect.' : 'Switched back to legacy board. Restart dsh web to take effect.')) } })
3761
+ } }, locale === 'zh' ? (boardModeLocal === 'graph' ? '看板版·点击回旧版' : '旧版白板·点击切看板') : (boardModeLocal === 'graph' ? 'Graph board · back to legacy' : 'Legacy board · switch to graph')),
3762
+ autoCfg ? h('button', { 'data-dam-btn': '', 'data-dam-key': 'autoContinueEnabled', style: { marginLeft: '8px' }, onClick: function () { autoSave({ autoContinueEnabled: !autoCfg.enabled }) } }, autoCfg.enabled ? t('autoContOn') : t('autoContOff')) : null,
3763
+ autoCfg ? h('label', { 'data-dam-hint': '', style: { marginLeft: '8px' } }, t('autoContThreshold') + ' ', h('input', { 'data-dam-input': '', type: 'number', min: 0.5, max: 0.95, step: 0.05, value: String(autoCfg.threshold), onChange: function (e) { var v = Number(e.target.value); if (v >= 0.5 && v <= 0.95) autoSave({ autoContinueThreshold: v }) } })) : null,
3764
+ ]),
3765
+ ]))
3766
+ // ★2026-09-17(L2 修 bug①): 面板侧同样**按真实 reason 分支** —— 旧实现一律显示 t('planDisabled')
3767
+ // (「白板未启用」), 与看板那句同源的误导。后端现在会带 reason: 'legacy-mode' / 'error'。
3768
+ if (!data.enabled) {
3769
+ var disMsg = (function () {
3770
+ var r = data && data.reason
3771
+ if (r === 'legacy-mode') {
3772
+ return locale === 'zh'
3773
+ ? '当前是旧版白板(legacy)档。看板需要切到「新版看板(dsh-graph)」——用上方开关切换, 重启 dsh web 后生效。'
3774
+ : 'Legacy board mode. The kanban needs the graph board — toggle it above, then restart dsh web.'
3775
+ }
3776
+ if (r === 'error') return (locale === 'zh' ? '白板加载出错: ' : 'Whiteboard load error: ') + String((data && data.error) || '')
3777
+ return t('planDisabled')
3778
+ })()
3779
+ return h('div', null, [switchCard, h(Card, { title: t('planTitle') }, h('div', { 'data-dam-hint': '' }, disMsg))])
3780
+ }
2336
3781
  var wl = data.waterLevel || {}
2337
3782
  // 2026-09-13(工作区切换修复):宿主对当前会话解析不出工作区身份时(wsBound:false),如实提示
2338
3783
  // 而不是渲染另一个会话/启动目录下的白板内容(旧全局单值行为的残余观感)。
@@ -2342,6 +3787,48 @@ window.__ModuleLoader__.load({
2342
3787
  } else {
2343
3788
  rows.push(h(Card, { title: t('planTitle') }, h('div', { 'data-dam-content': '' }, data.plan || t('planEmpty'))))
2344
3789
  }
3790
+ // ── 白板看板(2026-09-16 兼并 dsh-graph): graph 档在白板页顶部渲染列式泳道 ──
3791
+ // 数据来自 /kanban-board(自家 sidecar 投影); legacy 档 kb 为 null ⇒ 整块不渲染,
3792
+ // 页面与旧版逐字节相同。看板置顶: 它才是「一眼看全貌」的那一层, 文字白板退居其次。
3793
+ if (kbData) {
3794
+ rows.unshift(h(Card, { title: (locale === 'zh' ? '白板看板 · 全流程可视化' : 'Whiteboard kanban · full flow') }, h('div', { 'data-dam-content': '' }, h(KanbanBoard, { data: kbData }))))
3795
+ }
3796
+ // ── P2-5 前端(2026-09-16): 新版看板的结构化视图(tag 导航 + 段视图)──────────
3797
+ // 后端 handoffPanelData 已在 graph 档返回 structured(by_tag 倒排 + 按 kind 分组的条目摘要)。
3798
+ // legacy 档 structured 为 null ⇒ 本卡**完全不渲染**, 页面与旧版逐字节相同(开关解耦纪律)。
3799
+ //
3800
+ // v2(2026-09-16 晚): **看板可视化在场时隐藏这两张文字卡** —— 用户实测反馈「就是没有图」:
3801
+ // 它们与真看板内容重复(tag 导航的 0 个标签、段视图的条目流水), 同屏出现会让人以为
3802
+ // 这就是「新版看板」的全部。真看板(kbData)在场时, 文字卡退位; 其缺失时仍作兜底显示,
3803
+ // 保证「结构化数据始终可达」(不删功能, 只是不再与图争位)。
3804
+ if (data.structured && !kbData) {
3805
+ var st = data.structured
3806
+ var tagKids = []
3807
+ tagKids.push(h('div', { 'data-dam-hint': '', style: { marginBottom: '6px' } },
3808
+ (locale === 'zh' ? '结构化索引: ' : 'Structured index: ') + st.total + (locale === 'zh' ? ' 个条目 · ' : ' entries · ') + (st.tags || []).length + (locale === 'zh' ? ' 个标签' : ' tags') + (st.rebuiltAt ? ' · ' + String(st.rebuiltAt).slice(0, 19).replace('T', ' ') : '')))
3809
+ if ((st.tags || []).length) {
3810
+ tagKids.push(h('div', { 'data-dam-row': '', style: { flexWrap: 'wrap' } }, st.tags.map(function (tg) {
3811
+ return h('button', { 'data-dam-btn': '', key: 'tg-' + tg.tag, title: tg.tag, style: { marginRight: '6px', marginBottom: '6px' } },
3812
+ tg.tag + ' ×' + tg.count)
3813
+ })))
3814
+ tagKids.push(h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } },
3815
+ locale === 'zh' ? '标签来自账本/白板行内标记(tag:xxx / type:xxx / topic:xxx)。模型侧可用 memory_expand(tag) 按标签展开条目、memory_trace(id) 回溯来源与版本链。' : 'Tags come from inline markers (tag:xxx / type:xxx / topic:xxx). The model can use memory_expand(tag) to expand by tag and memory_trace(id) to trace origin and version chain.'))
3816
+ } else {
3817
+ tagKids.push(h('div', { 'data-dam-muted': '' }, locale === 'zh' ? '尚无标签——在账本/白板里写 tag:xxx 后重新写入即出现。' : 'No tags yet — write tag:xxx in a ledger/plan and re-save.'))
3818
+ }
3819
+ rows.push(h(Card, { title: locale === 'zh' ? '新版看板 · 标签导航' : 'Graph board · tag navigation' }, h('div', { 'data-dam-content': '' }, tagKids)))
3820
+ var secKids = []
3821
+ ;(st.sections || []).forEach(function (sc) {
3822
+ if (!sc.count) return
3823
+ secKids.push(h('div', { 'data-dam-hint': '', style: { marginTop: '6px', fontWeight: 600 } }, sc.kind + ' · ' + sc.count))
3824
+ ;(sc.items || []).forEach(function (it) {
3825
+ secKids.push(h('div', { style: { fontSize: 'calc(12px * var(--dam-scale))', marginLeft: '8px' } },
3826
+ h('span', { 'data-dam-hint': '' }, String(it.id || '').slice(4, 12) + ' '),
3827
+ h('span', { title: it.source || '' }, (it.section ? '§' + it.section + ' ' : '') + String(it.preview || '').slice(0, 90))))
3828
+ })
3829
+ })
3830
+ if (secKids.length) rows.push(h(Card, { title: locale === 'zh' ? '新版看板 · 段视图' : 'Graph board · sections' }, h('div', { 'data-dam-content': '' }, secKids)))
3831
+ }
2345
3832
  if (wl.window > 0) {
2346
3833
  // 2.2.4:百分比显示真实水位(旧版截断到 150% 会把「窗口算错」伪装成「刚好超一点」);进度条宽度仍按 100% 截断。
2347
3834
  var rawPct = Math.round((wl.ratio || 0) * 100)
@@ -2361,13 +3848,8 @@ window.__ModuleLoader__.load({
2361
3848
  ])))
2362
3849
  }
2363
3850
  if (autoCfg && wl.window > 0) {
2364
- rows.unshift(h(Card, { title: t('autoContTitle') }, h('div', { 'data-dam-content': '' }, [
2365
- h('div', { 'data-dam-hint': '', style: { marginBottom: '6px' } }, t('autoContHint')),
2366
- h('div', { 'data-dam-row': '' },
2367
- h('button', { 'data-dam-btn': '', onClick: function () { autoSave({ autoContinueEnabled: !autoCfg.enabled }) } }, autoCfg.enabled ? t('autoContOn') : t('autoContOff')),
2368
- h('label', { 'data-dam-hint': '', style: { marginLeft: '8px' } }, t('autoContThreshold') + ' ',
2369
- h('input', { 'data-dam-input': '', type: 'number', min: 0.5, max: 0.95, step: 0.05, value: String(autoCfg.threshold), style: { width: '64px' }, onChange: function (e) { var v = Number(e.target.value); if (v >= 0.5 && v <= 0.95) autoSave({ autoContinueThreshold: v }) } }))),
2370
- ])))
3851
+ // 2026-09-14:开关与阈值统一收进顶部常显的开关卡(同一配置键只应有一个入口),此处只留说明。
3852
+ rows.unshift(h(Card, { title: t('autoContTitle') }, h('div', { 'data-dam-content': '' }, h('div', { 'data-dam-hint': '' }, t('autoContHint')))))
2371
3853
  }
2372
3854
  if (open) {
2373
3855
  rows.unshift(h(Card, { title: t('planFileTitle') + ' · ' + open }, h('div', { 'data-dam-content': '' }, content || t('empty'))))
@@ -2388,7 +3870,9 @@ window.__ModuleLoader__.load({
2388
3870
  contMsg ? h('div', { 'data-dam-hint': '', style: { marginTop: '4px' } }, contMsg) : null,
2389
3871
  ])))
2390
3872
  }
2391
- return h('div', null, rows)
3873
+ // 2026-09-14 修复:开关卡此前只在「未启用」分支被返回 ⇒ 白板一旦开启,白板页就再也看不到开关(开得了、关不掉)。
3874
+ // 开关卡必须在**两种**状态下都渲染 —— 它是这个页面上唯一的开关入口。
3875
+ return h('div', null, [switchCard].concat(rows))
2392
3876
  }
2393
3877
 
2394
3878
  function ReflectionsTab() {
@@ -2558,13 +4042,13 @@ window.__ModuleLoader__.load({
2558
4042
  h('g', null, children.map(function (n, i) {
2559
4043
  return h('g', { key: 'branch-' + i },
2560
4044
  h('line', { x1: n.center.x, y1: n.center.y + 38, x2: n.x, y2: n.y - 22, stroke: '#8b949e', strokeOpacity: .48, strokeWidth: 1.2 }),
2561
- h('rect', { x: n.x - (compact ? 82 : 92), y: n.y - 22, width: compact ? 164 : 184, height: 44, rx: 10, fill: 'color-mix(in srgb, var(--dsw-alias-bg-overlay, #fff) 92%, transparent)', stroke: 'color-mix(in srgb, var(--dam-accent, #1d4ed8) 42%, #8b949e)', strokeOpacity: .8 }),
4045
+ h('rect', { x: n.x - (compact ? 82 : 92), y: n.y - 22, width: compact ? 164 : 184, height: 44, rx: 10, fill: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2, #fff) 92%, transparent)', stroke: 'color-mix(in srgb, var(--dam-accent, #1d4ed8) 42%, #8b949e)', strokeOpacity: .8 }),
2562
4046
  h('text', { x: n.x, y: n.y - 5, textAnchor: 'middle', fontSize: compact ? 9.5 : 10.5, fontWeight: 650, fill: 'currentColor' }, String(n.item && n.item.label || n.item || '').slice(0, 22)),
2563
4047
  h('text', { x: n.x, y: n.y + 10, textAnchor: 'middle', fontSize: 8, fill: '#7d8793' }, String(n.item && n.item.detail || '').slice(0, 26)))
2564
4048
  })),
2565
4049
  h('g', null, centers.map(function (c) {
2566
4050
  return h('g', { key: c.ws.path, 'data-dam-graph-node': '', onPointerDown: function (ev) { ev.stopPropagation() }, onClick: function () { onSelect(c.ws) } },
2567
- h('rect', { x: c.x - 100, y: c.y - 38, width: 200, height: 76, rx: 16, fill: 'color-mix(in srgb, var(--dam-accent, #1d4ed8) 22%, var(--dsw-alias-bg-overlay, #fff))', stroke: 'var(--dam-accent, #1d4ed8)', strokeWidth: 2 }),
4051
+ h('rect', { x: c.x - 100, y: c.y - 38, width: 200, height: 76, rx: 16, fill: 'color-mix(in srgb, var(--dam-accent, #1d4ed8) 22%, var(--dsw-alias-bg-layer-2, #fff))', stroke: 'var(--dam-accent, #1d4ed8)', strokeWidth: 2 }),
2568
4052
  h('text', { x: c.x, y: c.y - 5, textAnchor: 'middle', fontSize: compact ? 10.5 : 12, fontWeight: 650, fill: 'currentColor' }, h('tspan', { x: c.x, dy: 0 }, String(c.ws.name || '').slice(0, 12)), h('tspan', { x: c.x, dy: 13 }, String(c.ws.name || '').slice(12, 24))),
2569
4053
  h('text', { x: c.x, y: c.y + 15, textAnchor: 'middle', fontSize: 8, fill: '#7d8793' }, (c.ws.logCount || 0) + ' ' + t('logEntries')))
2570
4054
  }))))
@@ -2622,6 +4106,7 @@ window.__ModuleLoader__.load({
2622
4106
  '未分类': { color: 'var(--dsw-alias-label-secondary, #8a94a6)' },
2623
4107
  }
2624
4108
  function CalendarTab() {
4109
+ var zh = (locale || 'zh').indexOf('zh') === 0
2625
4110
  var dataPair = useState(null)
2626
4111
  var data = dataPair[0]
2627
4112
  var setData = dataPair[1]
@@ -2748,16 +4233,61 @@ window.__ModuleLoader__.load({
2748
4233
  // 日期格先打开当天时间轴;点击某个时间槽才创建事件。
2749
4234
  if (dayView) {
2750
4235
  var timeline = []
2751
- for (var hourSlot = 7; hourSlot <= 22; hourSlot++) {
4236
+ var dayAll = dayEntries(dayView)
4237
+ // ★2026-09-20 修 bug: 旧实现把时间轴写死为 `for (hourSlot = 7; hourSlot <= 22; ...)`。
4238
+ // 后果: 任何 23:00–06:59 或 time='--:--' 的事件**在当天视图里永不显示**——
4239
+ // 而用户的真实数据里就有 7 条 23:59 截止的作业(AMA1751 / BME1D02)。
4240
+ // 现改为**由数据驱动**: 先扫当天事件的真实小时, 用其 min/max 决定时间轴范围
4241
+ // (无事件时才回落到 07:00–22:00), 且任何落在范围外的事件单独成"时间轴外"一段。
4242
+ var hourNums = []
4243
+ var untimed = []
4244
+ for (var di = 0; di < dayAll.length; di++) {
4245
+ var tm = String(dayAll[di].time || '')
4246
+ var hh = parseInt(tm.slice(0, 2), 10)
4247
+ if (isNaN(hh)) { untimed.push(dayAll[di]); continue }
4248
+ hourNums.push(hh)
4249
+ }
4250
+ var hFrom = hourNums.length ? Math.min.apply(null, hourNums) : 7
4251
+ var hTo = hourNums.length ? Math.max.apply(null, hourNums) : 22
4252
+ if (hFrom > 7) hFrom = 7
4253
+ if (hTo < 22) hTo = 22
4254
+ // 每个小时槽: 支持同名 key 去重(同小时多事件)
4255
+ for (var hourSlot = hFrom; hourSlot <= hTo; hourSlot++) {
2752
4256
  (function (hourValue) {
2753
4257
  var hourText = String(hourValue).padStart(2, '0') + ':00'
2754
- var hourEvents = dayEntries(dayView).filter(function (en) { return String(en.time || '').slice(0, 2) === String(hourValue).padStart(2, '0') })
4258
+ var pad = String(hourValue).padStart(2, '0')
4259
+ var hourEvents = dayAll.filter(function (en) { return String(en.time || '').slice(0, 2) === pad })
2755
4260
  timeline.push(h('div', { key: hourText, 'data-dam-calendar-slot': '', onClick: function () { setDayView(null); setDraft({ date: dayView, time: hourText, quadrant: '重要不紧急', title: '', location: '', reminder: '', note: '' }) } },
2756
4261
  h('div', { style: { flex: '0 0 50px', fontSize: 'calc(11px * var(--dam-scale))', opacity: .65 } }, hourText),
2757
- h('div', { style: { minHeight: '34px', flex: 1, borderLeft: '2px solid color-mix(in srgb, var(--dam-accent, #1d4ed8) 24%, transparent)', paddingLeft: '9px' } }, hourEvents.length ? hourEvents.map(function (en) { var qs = QUADRANT_STYLE[en.quadrant] || QUADRANT_STYLE['未分类']; return h('div', { key: en.time + en.title, 'data-dam-calendar-event': '', onClick: function (ev) { ev.stopPropagation(); toggleDone(en) }, style: { marginBottom: '4px', padding: '4px 7px', borderRadius: 6, background: 'color-mix(in srgb, ' + qs.color + ' 18%, transparent)', color: qs.color, textDecoration: en.done ? 'line-through' : 'none' } }, (en.time || '') + ' ' + en.title + (en.note ? ' · ' + en.note : '')) }) : h('span', { 'data-dam-hint': '' }, locale === 'zh' ? '点击添加事件' : 'Click to add event'))))
4262
+ h('div', { style: { minHeight: '34px', flex: 1, borderLeft: '2px solid color-mix(in srgb, var(--dam-accent, #1d4ed8) 24%, transparent)', paddingLeft: '9px' } }, hourEvents.length ? hourEvents.map(function (en, k) { var qs = QUADRANT_STYLE[en.quadrant] || QUADRANT_STYLE['未分类']; return h('div', { key: String(en.time) + '#' + k, 'data-dam-calendar-event': '', onClick: function (ev) { ev.stopPropagation(); toggleDone(en) }, style: { marginBottom: '4px', padding: '4px 7px', borderRadius: 6, background: 'color-mix(in srgb, ' + qs.color + ' 18%, transparent)', color: qs.color, textDecoration: en.done ? 'line-through' : 'none', opacity: en.done ? .55 : 1, cursor: 'pointer' } }, (en.time || '') + ' ' + en.title + (en.note ? ' · ' + en.note : '')) }) : h('span', { 'data-dam-hint': '' }, locale === 'zh' ? '点击添加事件' : 'Click to add event'))))
2758
4263
  })(hourSlot)
2759
4264
  }
2760
- rows.push(h('div', { 'data-dam-calendar-modal': '', style: { position: 'absolute', inset: 0, zIndex: 10, overflow: 'auto', borderRadius: '16px', background: 'color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(255,255,255,.9)) 78%, transparent)', backdropFilter: 'blur(20px) saturate(1.4)', WebkitBackdropFilter: 'blur(20px) saturate(1.4)', padding: '16px' } }, h('div', { 'data-dam-row': '' }, h('div', { style: { fontWeight: 700, flex: 1 } }, dayView + ' · ' + (locale === 'zh' ? '当天安排' : 'Day schedule')), h('button', { 'data-dam-btn': '', onClick: function () { setDayView(null) } }, '✕')), timeline))
4265
+ // 时间轴外(无 time 或 time 无法解析)的事件单独列出 —— 不再静默丢失
4266
+ if (untimed.length) {
4267
+ timeline.push(h('div', { key: '__untimed', 'data-dam-calendar-slot': '', onClick: function () { setDayView(null); setDraft({ date: dayView, time: '23:59', quadrant: '重要不紧急', title: '', location: '', reminder: '', note: '' }) } },
4268
+ h('div', { style: { flex: '0 0 50px', fontSize: 'calc(11px * var(--dam-scale))', opacity: .65 } }, zh ? '未定时' : 'n/a'),
4269
+ h('div', { style: { minHeight: '34px', flex: 1, borderLeft: '2px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d4a94f) 55%, transparent)', paddingLeft: '9px' } }, untimed.map(function (en, k) { var qs = QUADRANT_STYLE[en.quadrant] || QUADRANT_STYLE['未分类']; return h('div', { key: 'u' + k, 'data-dam-calendar-event': '', onClick: function (ev) { ev.stopPropagation(); toggleDone(en) }, style: { marginBottom: '4px', padding: '4px 7px', borderRadius: 6, background: 'color-mix(in srgb, ' + qs.color + ' 18%, transparent)', color: qs.color, textDecoration: en.done ? 'line-through' : 'none', opacity: en.done ? .55 : 1, cursor: 'pointer' } }, en.title + (en.note ? ' · ' + en.note : '')) }))))
4270
+ }
4271
+ // 当天前后翻页(原实现只能靠「先关掉浮层 → 再点另一个日期」, 多一次无效操作)
4272
+ var dPrev = new Date(dayView + 'T00:00:00'); dPrev.setDate(dPrev.getDate() - 1)
4273
+ var dNext = new Date(dayView + 'T00:00:00'); dNext.setDate(dNext.getDate() + 1)
4274
+ var fmtD = function (dt) { return dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0') + '-' + String(dt.getDate()).padStart(2, '0') }
4275
+ var wk = (function (ds) {
4276
+ // ISO-8601 周序号(周一为一周之始, 第 1 周含当年首个周四)
4277
+ var dt = new Date(ds + 'T00:00:00')
4278
+ var t = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
4279
+ t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7))
4280
+ var ws = new Date(t.getFullYear(), 0, 4)
4281
+ ws.setDate(ws.getDate() + 3 - ((ws.getDay() + 6) % 7))
4282
+ return 1 + Math.round((t - ws) / 604800000)
4283
+ })(dayView)
4284
+ rows.push(h('div', { 'data-dam-calendar-modal': '', style: { position: 'absolute', inset: 0, zIndex: 10, overflow: 'auto', borderRadius: '16px', background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(255,255,255,.9)) 78%, transparent)', backdropFilter: 'blur(20px) saturate(1.4)', WebkitBackdropFilter: 'blur(20px) saturate(1.4)', padding: '16px' } },
4285
+ h('div', { 'data-dam-row': '' },
4286
+ h('button', { 'data-dam-btn': '', title: zh ? '前一天' : 'previous day', onClick: function () { setDayView(fmtD(dPrev)) } }, '‹'),
4287
+ h('div', { style: { fontWeight: 700, flex: 1, textAlign: 'center' } }, dayView + ' · ' + (zh ? '第 ' + wk + ' 周' : 'W' + wk) + ' · ' + dayAll.length + (zh ? ' 项' : ' items')),
4288
+ h('button', { 'data-dam-btn': '', title: zh ? '后一天' : 'next day', onClick: function () { setDayView(fmtD(dNext)) } }, '›'),
4289
+ h('button', { 'data-dam-btn': '', onClick: function () { setDayView(null) } }, '✕')),
4290
+ timeline))
2761
4291
  }
2762
4292
  // 添加/编辑浮层(液态玻璃)
2763
4293
  if (draft) {
@@ -2765,7 +4295,7 @@ window.__ModuleLoader__.load({
2765
4295
  'data-dam-calendar-modal': '',
2766
4296
  style: {
2767
4297
  position: 'absolute', inset: 0, zIndex: 10, borderRadius: '16px',
2768
- background: 'color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(255,255,255,.9)) 70%, transparent)',
4298
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(255,255,255,.9)) 70%, transparent)',
2769
4299
  backdropFilter: 'blur(20px) saturate(1.4)', WebkitBackdropFilter: 'blur(20px) saturate(1.4)',
2770
4300
  display: 'flex', flexDirection: 'column', padding: '18px', gap: '10px',
2771
4301
  border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.35)) 60%, transparent)',
@@ -3016,7 +4546,7 @@ window.__ModuleLoader__.load({
3016
4546
  return function () { alive = false }
3017
4547
  }, [])
3018
4548
  // 可读性兜底(@ProperSAMA PR#12,适配版):DSH Desktop 增强模式 + 透明/Mica 窗口材质下,
3019
- // 主题令牌 --dsw-alias-bg-overlay 本身就是半透明的,面板文字几乎不可读。判别信号用
4549
+ // 主题令牌 --dsw-alias-bg-layer-2 本身就是半透明的,面板文字几乎不可读。判别信号用
3020
4550
  // 「令牌自身的 alpha」(增强模式显著低于普通模式的 0.86-0.9):< 0.65 时把面板背景提升到
3021
4551
  // 0.96(保留色相)并弱化顶部高光。普通模式令牌不透明 → 不触发,液态玻璃观感零变化。
3022
4552
  useEffect(function () {
@@ -3025,7 +4555,7 @@ window.__ModuleLoader__.load({
3025
4555
  if (!el) return
3026
4556
  try {
3027
4557
  var cs = getComputedStyle(el)
3028
- var token = cs.getPropertyValue('--dsw-alias-bg-overlay')
4558
+ var token = cs.getPropertyValue('--dsw-alias-bg-layer-2')
3029
4559
  var col = parseCssColor((token || '').trim() || cs.backgroundColor)
3030
4560
  if (col && col.a < 0.65) {
3031
4561
  el.style.background = 'rgba(' + col.r + ', ' + col.g + ', ' + col.b + ', 0.96)'
@@ -3252,20 +4782,26 @@ window.__ModuleLoader__.load({
3252
4782
  if (ph === 'downloading' || ph === 'verifying' || ph === 'done' || ph === 'error') return undefined
3253
4783
  apiPost(API.semanticDownload, { action: 'start', mirror: 'auto' }).catch(function () {})
3254
4784
  }, [tourShowing, tourStep, wizSt.ready])
4785
+ // issue#40:向导展示期间装焦点陷阱(移动端浮层吞点击/Esc 关不掉的关键一环)。
4786
+ useEffect(function () {
4787
+ if (!dialogState || dialogState.kind !== 'welcomeTour') return undefined
4788
+ var root = document.querySelector('[data-dam-tour]')
4789
+ return installTourFocusPre(root, function () { dismissWelcomeTourPre('user') }, document)
4790
+ }, [dialogState ? dialogState.kind : null])
3255
4791
  if (!dialogState) return null
3256
4792
  // 左下角小卡片(记忆按钮上方),高透明毛玻璃,不遮全屏
3257
4793
  var overlay = { position: 'fixed', left: '10px', bottom: '64px', zIndex: 2147483000, width: 'min(360px, calc(100vw - 20px))', maxHeight: 'min(46vh, 430px)', display: 'flex', flexDirection: 'column' }
3258
4794
  var box = {
3259
4795
  overflow: 'auto', borderRadius: '14px', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '6px',
3260
- background: 'color-mix(in srgb, var(--dsw-alias-bg-overlay, rgba(24,26,32,.9)) 58%, transparent)',
4796
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2, rgba(24,26,32,.9)) 58%, transparent)',
3261
4797
  backdropFilter: 'blur(14px) saturate(1.3)', WebkitBackdropFilter: 'blur(14px) saturate(1.3)',
3262
4798
  border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.35)) 45%, transparent)',
3263
4799
  boxShadow: '0 8px 28px rgba(0,0,0,.18)',
3264
4800
  }
3265
- var head = { fontSize: 'calc(13px * var(--dam-scale))', fontWeight: 700, color: 'var(--dsw-alias-text-primary, inherit)' }
4801
+ var head = { fontSize: 'calc(13px * var(--dam-scale))', fontWeight: 700, color: 'var(--dsw-alias-label-primary, inherit)' }
3266
4802
  var sub = { fontSize: 'calc(11px * var(--dam-scale))', opacity: .7 }
3267
4803
  var item = { fontSize: 'calc(11px * var(--dam-scale))', lineHeight: 1.55, padding: '1px 0 1px 16px', position: 'relative' }
3268
- var close = { alignSelf: 'flex-end', padding: '4px 16px', borderRadius: '8px', border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.35)) 45%, transparent)', background: 'color-mix(in srgb, var(--dsw-alias-brand-primary, #4f7cff) 12%, transparent)', color: 'var(--dsw-alias-text-primary, inherit)', cursor: 'pointer', fontSize: 'calc(11.5px * var(--dam-scale))' }
4804
+ var close = { alignSelf: 'flex-end', padding: '4px 16px', borderRadius: '8px', border: '1px solid color-mix(in srgb, var(--dsw-alias-border-l1, rgba(128,128,128,.35)) 45%, transparent)', background: 'color-mix(in srgb, var(--dsw-alias-brand-primary, #4f7cff) 12%, transparent)', color: 'var(--dsw-alias-label-primary, inherit)', cursor: 'pointer', fontSize: 'calc(11.5px * var(--dam-scale))' }
3269
4805
  var dot = { position: 'absolute', left: '0', top: '10px', width: '7px', height: '7px', borderRadius: '50%', background: 'var(--dsw-alias-brand-primary, #4f7cff)' }
3270
4806
  // 通用开场舞台必须在 first/notice/update 三个分支之前完成赋值;var 只提升声明、不提升赋值。
3271
4807
  var skipUpdateIntro = function () { setUpdateSkip(true) }
@@ -3343,6 +4879,12 @@ window.__ModuleLoader__.load({
3343
4879
  { key: 'activationEmitMode', name: locale === 'zh' ? '记忆唤起(链中检测→插入)' : 'Memory recall (detect → inject)', sub: locale === 'zh' ? '开=canary 档(显式回忆注入,推荐)/ 关=shadow 档(只记录不注入)' : 'On = canary (inject on explicit recall, recommended) / Off = shadow (record only)', mode: true, rec: true, where: locale === 'zh' ? '自动记忆引擎' : 'Semantic engine' },
3344
4880
  { key: 'autoConsolidate', name: locale === 'zh' ? '记忆固化(自动沉淀)' : 'Memory consolidation', sub: locale === 'zh' ? '每轮对话结束自动把结论沉淀进每日日志与记忆店(推荐开)' : 'Consolidate conclusions into the log & stores each turn (recommended)', rec: true, where: locale === 'zh' ? '自动化' : 'Automation' },
3345
4881
  { key: 'procedurePromotionEnabled', name: locale === 'zh' ? '技能固化与晋升' : 'Skill crystallization', sub: locale === 'zh' ? '重复流程固化为 checklist 自动附上;跨会话验证后晋升(「记忆中枢」页审批)' : 'Turn repeated flows into auto-attached checklists; promote after validation (Memory Hub tab)', where: locale === 'zh' ? '记忆中枢' : 'Memory Hub' },
4882
+ // ★T10(2026-09-20):机械 procedure 切片开关。
4883
+ // 背景:`crossFeed()` 会把 episode 意图**机械截断**成技能名与步骤(无模型介入),
4884
+ // 产出的观察行「既不像技能名也不像步骤」——用户报「审批里看不懂」的来源。
4885
+ // 默认关:procedural 线只留模型直写(memory_procedure)与人工两条正经通路。
4886
+ // ⚠️ 解耦:本项**只**影响 procedure 切片,fact 分支/episode 巩固/judgement 消费不动。
4887
+ { key: 'hubMechanicalProcedureFeedEnabled', name: locale === 'zh' ? '机械流程切片(不推荐)' : 'Mechanical flow slicing (not recommended)', sub: locale === 'zh' ? '把情节意图截断成"技能",无模型介入、可读性差;关=只保留模型自己写的技能(推荐关)' : 'Truncates episode intent into "skills" with no model involvement; off = only model-authored skills (recommended)', where: locale === 'zh' ? '记忆中枢' : 'Memory Hub' },
3346
4888
  ] },
3347
4889
  { art: 'rocket', core: '', kicker: locale === 'zh' ? '完成' : 'READY', final: true,
3348
4890
  title: locale === 'zh' ? '一切就绪' : 'All set',
@@ -3434,11 +4976,9 @@ window.__ModuleLoader__.load({
3434
4976
  })
3435
4977
  } catch (eChain) {}
3436
4978
  }
3437
- var closeOrRemind = function () {
3438
- // 关闭时机提醒:未到完成步就关 → 先落到完成步(重开位置指引),再点才开始使用
3439
- if (tourStep < TOUR_STEPS.length - 1) setTourStep(TOUR_STEPS.length - 1)
3440
- else finishTour()
3441
- }
4979
+ // issue#40:"关闭"就是关闭 —— 旧实现把 ✕ 变成"先跳到最后一步再点",手机端体感即"关不掉"。
4980
+ // 现在 ✕ 直接撤下向导并记录**用户动作**(tourDismissed),此后不再自动播放。
4981
+ var closeOrRemind = function () { dismissWelcomeTourPre('user') }
3442
4982
  var step = TOUR_STEPS[Math.min(tourStep, TOUR_STEPS.length - 1)]
3443
4983
  var isLast = tourStep >= TOUR_STEPS.length - 1
3444
4984
  // 每步只生成当前图形所需 DOM;避免旧实现一次创建 22 个 span、非当前零件塌成 0/2px。
@@ -3465,7 +5005,7 @@ window.__ModuleLoader__.load({
3465
5005
  el.style.setProperty('--dam-mx', (((e.clientX - r.left) / r.width) * 100).toFixed(1) + '%')
3466
5006
  el.style.setProperty('--dam-my', (((e.clientY - r.top) / r.height) * 100).toFixed(1) + '%')
3467
5007
  } },
3468
- h('div', { 'data-dam-tour': '' },
5008
+ h('div', { 'data-dam-tour': '', role: 'dialog', 'aria-modal': true, tabIndex: -1, 'aria-label': locale === 'zh' ? '欢迎向导' : 'Welcome tour' },
3469
5009
  h('div', { 'data-dam-tour-glare': '' }),
3470
5010
  h('button', { 'data-dam-tour-close': '', title: t('close'), onClick: closeOrRemind }, '✕'),
3471
5011
  h('div', { 'data-dam-tour-orb-wrap': '', key: 'orb' + tourStep, 'data-step': String(tourStep), 'data-art': step.art || 'bubble' },
@@ -3549,7 +5089,7 @@ window.__ModuleLoader__.load({
3549
5089
  var nTitle = zh ? (n.title || '') : (n.titleEn || n.title || '')
3550
5090
  var nMsg = zh ? (n.message || '') : (n.messageEn || n.message || '')
3551
5091
  var isUrgent = n.level === 'urgent'
3552
- var accent = isUrgent ? 'var(--dsw-alias-danger, #e5534b)' : 'var(--dsw-alias-brand-primary, #4f7cff)'
5092
+ var accent = isUrgent ? 'var(--dsw-alias-state-error-primary, #e5534b)' : 'var(--dsw-alias-brand-primary, #4f7cff)'
3553
5093
  var noticeButton = h('button', { 'data-dam-btn': '', style: close, onClick: function () {
3554
5094
  try {
3555
5095
  var arr = []
@@ -3696,7 +5236,7 @@ window.__ModuleLoader__.load({
3696
5236
  // 带上当前会话 id:宿主据此只在「属于本窗口的」armed 上弹确认卡(2026-09-10)
3697
5237
  var sidQ = ''
3698
5238
  try { sidQ = String(currentSessionIdClient() || '') } catch (eS) {}
3699
- apiGet(API.config).then(function (c) { if (alive) setAcCfg(c || {}) }).catch(function () {})
5239
+ apiGet(API.config).then(function (d) { if (alive) setAcCfg(configOf(d)) }).catch(function () {})
3700
5240
  apiGet(API.autoContState, sidQ ? { sessionId: sidQ } : {}).then(function (s) { if (alive && s) setAcState(s) }).catch(function () {})
3701
5241
  }
3702
5242
  load()
@@ -3740,7 +5280,8 @@ window.__ModuleLoader__.load({
3740
5280
  // ring/wall 必须一起带进来:卡片读的是 acConfirm.ring / acConfirm.wall,
3741
5281
  // 宿主透出了但这里不拷 → 双口径那行永远不渲染(2026-09-10 自查发现)
3742
5282
  setAcConfirm({ ratio: Number(arm.ratio) || 0, tokens: Number(arm.tokens) || 0, window: Number(arm.window) || 0, ring: Number(arm.ring) || 0, wall: Number(arm.wall) || 0, edgeAt: Number(arm.edgeAt) || 0 })
3743
- setAcCd(Math.max(0, Math.ceil((Number(arm.expiresAt) || 0) - Date.now()) / 1000))
5283
+ // ★2026-09-20 移植(issue #94⑦ / PR #100):ceil 作用于**毫秒**再除 1000 ⇒ 显示小数秒。
5284
+ setAcCd(Math.max(0, Math.ceil(((Number(arm.expiresAt) || 0) - Date.now()) / 1000)))
3744
5285
  } else {
3745
5286
  setAcConfirm(null)
3746
5287
  setAcCd(0)
@@ -3889,12 +5430,12 @@ window.__ModuleLoader__.load({
3889
5430
  if (mdlLoading) kids.push(h('div', { 'data-dam-hint': '' }, locale === 'zh' ? '正在检测可用模型…' : 'Detecting models…'))
3890
5431
  if (mdlErr) kids.push(h('div', { 'data-dam-error': '' }, mdlErr))
3891
5432
  if (!mdlLoading && !mdlErr && mdlData) {
3892
- kids.push(h('button', { key: '__default__', 'data-dam-btn': '', style: { display: 'block', width: '100%', textAlign: 'left', padding: '4px 6px', opacity: cfg.subagentModel ? 1 : 0.75 }, onClick: function () { set('subagentModel', ''); set('subagentProvider', ''); setMdlOpen(false) } },
5433
+ kids.push(h('button', { key: '__default__', 'data-dam-btn': '', style: { display: 'block', width: '100%', textAlign: 'left', padding: '4px 6px', opacity: cfg.subagentModel ? 1 : 0.75 }, onClick: function () { setMany({ subagentModel: '', subagentProvider: '' }); setMdlOpen(false) } },
3893
5434
  locale === 'zh' ? '跟随路由默认(留空)' : 'Follow routing default (empty)'))
3894
5435
  ;(mdlData.providers || []).forEach(function (p) {
3895
5436
  var modelBtns = (p.models || []).length
3896
5437
  ? p.models.map(function (m) {
3897
- return h('button', { key: p.id + '/' + m.id, 'data-dam-btn': '', style: { display: 'block', width: '100%', textAlign: 'left', padding: '3px 6px', fontWeight: cfg.subagentModel === m.id ? 700 : 400 }, onClick: function () { set('subagentModel', m.id); set('subagentProvider', p.id); setMdlOpen(false) } },
5438
+ return h('button', { key: p.id + '/' + m.id, 'data-dam-btn': '', style: { display: 'block', width: '100%', textAlign: 'left', padding: '3px 6px', fontWeight: cfg.subagentModel === m.id ? 700 : 400 }, onClick: function () { setMany({ subagentModel: m.id, subagentProvider: p.id }); setMdlOpen(false) } },
3898
5439
  m.id + (m.name && m.name !== m.id ? ' · ' + m.name : '') + (cfg.subagentModel === m.id ? ' ✓' : ''))
3899
5440
  })
3900
5441
  : [h('div', { key: 'none', 'data-dam-hint': '' }, locale === 'zh' ? '(该 provider 未列出模型)' : '(no models advertised)')]
@@ -3908,7 +5449,7 @@ window.__ModuleLoader__.load({
3908
5449
  if (!(mdlData.providers || []).length && !(mdlData.failures || []).length) {
3909
5450
  kids.push(h('div', { 'data-dam-hint': '' }, locale === 'zh' ? '未检测到 provider;可直接在下方手动输入。' : 'No providers detected; use manual input below.'))
3910
5451
  }
3911
- kids.push(h('input', { key: '__manual__', 'data-dam-input': '', style: { marginTop: '6px', width: '100%' }, value: cfg.subagentModel || '', placeholder: locale === 'zh' ? '手动输入(可选,provider 跟随路由默认)' : 'manual entry (optional, provider follows routing default)', onChange: function (e) { set('subagentModel', String(e.target.value || '').trim()); set('subagentProvider', '') } }))
5452
+ kids.push(h('input', { key: '__manual__', 'data-dam-input': '', style: { marginTop: '6px', width: '100%' }, value: cfg.subagentModel || '', placeholder: locale === 'zh' ? '手动输入(可选,provider 跟随路由默认)' : 'manual entry (optional, provider follows routing default)', onChange: function (e) { setMany({ subagentModel: String(e.target.value || '').trim(), subagentProvider: '' }) } }))
3912
5453
  }
3913
5454
  // 思考强度(DSH 0.1.5 agentOptions.reasoningEffort):与模型成对下发给子代理;留空=跟随模型默认。
3914
5455
  // 合法值按 DeepSeek 适配器口径 off|low|high|max(默认 high)——服务端同样做白名单过滤。
@@ -3994,7 +5535,15 @@ window.__ModuleLoader__.load({
3994
5535
  return function () { clearInterval(iv) }
3995
5536
  }, [sem && sem.download && sem.download.phase])
3996
5537
  if (!cfg) return err ? h('div', { 'data-dam-error': '' }, err) : h(Loading)
3997
- function set(key, value) { var next = Object.assign({}, cfg); next[key] = value; setCfg(next); setDirty(true) }
5538
+ // issue #52:旧实现 `var next = Object.assign({}, cfg); next[key] = value` 读的是**本次渲染的闭包快照**。
5539
+ // 同一个事件里连续调 `set('subagentModel', m); set('subagentProvider', p)` 时,React 批处理两次
5540
+ // 更新,而第二次仍基于同一个旧 cfg ⇒ 把第一次写进去的值**覆盖回旧值**。
5541
+ // 典型症状:subagentModel 存过一个后来被删除的模型后,无论换模型/清空/手输都无法覆盖,
5542
+ // 插件持续按已删除模型派子代理(issue #52)。
5543
+ // 修法:改用函数式更新,基于**最新** prev 派生 next,成对字段不再互相覆盖。
5544
+ function set(key, value) { setCfg(function (prev) { var next = Object.assign({}, prev); next[key] = value; return next }); setDirty(true) }
5545
+ /** 原子更新多个字段(issue #52 建议口径):一次函数式更新写入成对字段,避免先后覆盖。 */
5546
+ function setMany(patch) { setCfg(function (prev) { return Object.assign({}, prev, patch) }); setDirty(true) }
3998
5547
  function checkUpdate() {
3999
5548
  if (checkingUpdate) return
4000
5549
  setCheckingUpdate(true)
@@ -4013,10 +5562,20 @@ window.__ModuleLoader__.load({
4013
5562
  function save() {
4014
5563
  if (busy) return
4015
5564
  setBusy(true); setMsg(''); setErr('')
4016
- saveConfigPatch(cfg, {
4017
- onSaved: function (d) { setCfg(d.config); setDirty(false); setMsg(t('saved') + (d.migrated ? ' ' + d.migrated : '')); setBusy(false); if (d && d.config && d.config.locale) applyLocalePref(d.config.locale); refreshSem(setSem) },
4018
- onError: function (e) { setErr(e.message); setBusy(false) }
4019
- })
5565
+ // 2026-09-14 修「跨入口互相覆盖」:旧实现把整份 cfg 快照 POST,而宿主端语义是 { ...this.config, ...patch } 合并
5566
+ // ⇒ 只要本页加载之后又从别的入口(白板页开关 / 向导 / 接续页)改过任何键,这里一保存就会把它们**回滚**成
5567
+ // 加载时的旧值(用户观感:「打开白板,原有设置被覆盖」)。改为先取回宿主当前配置,只提交与远端不同的键。
5568
+ apiGet(API.config).then(function (d0) {
5569
+ var remote = (d0 && d0.config) || {}
5570
+ var patch = {}
5571
+ Object.keys(cfg).forEach(function (k) {
5572
+ if (JSON.stringify(cfg[k]) !== JSON.stringify(remote[k])) patch[k] = cfg[k]
5573
+ })
5574
+ saveConfigPatch(patch, {
5575
+ onSaved: function (d) { setCfg(d.config); setDirty(false); setMsg(t('saved') + (d.migrated ? ' ' + d.migrated : '')); setBusy(false); if (d && d.config && d.config.locale) applyLocalePref(d.config.locale); refreshSem(setSem) },
5576
+ onError: function (e) { setErr(e.message); setBusy(false) }
5577
+ })
5578
+ }).catch(function (e) { setErr(e.message); setBusy(false) })
4020
5579
  }
4021
5580
  function field(label, control, hint) {
4022
5581
  return h('div', { 'data-dam-settings-row': '' },
@@ -4188,6 +5747,9 @@ window.__ModuleLoader__.load({
4188
5747
  else if (dl.phase === 'error') { stateTxt = t('dlError'); stateBg = 'rgba(196,74,74,.22)' }
4189
5748
  else if (dl.phase === 'cancelled') { stateTxt = t('dlCancelled'); stateBg = 'rgba(196,138,42,.2)' }
4190
5749
  else if (isJs && dlActive) { stateTxt = dl.phase === 'verifying' ? t('dlVerifying') : t('dlDownloading'); stateBg = 'rgba(36,86,196,.2)' }
5750
+ // ★ issue #70(2026-09-19):资产齐备但引擎**已降级** —— 必须与「未下载」区分开。
5751
+ // 旧实现只看文件存在性 ⇒ 这种状态会显示「已就绪 ✓」而实际每次检索都在词法兜底。
5752
+ else if (isJs && sem.degraded) { stateTxt = locale === 'zh' ? ('引擎降级(词法兜底)— ' + String(sem.degraded).slice(0, 60)) : ('Engine degraded (lexical fallback) — ' + String(sem.degraded).slice(0, 60)); stateBg = 'rgba(196,74,74,.22)' }
4191
5753
  else if (isJs && sem.assetPresent && !sem.peerPresent) { stateTxt = locale === 'zh' ? '模型已存在,缺推理库——pnpm approve-builds 后 pnpm add @huggingface/transformers' : 'Model present, runtime missing — pnpm approve-builds && pnpm add @huggingface/transformers'; stateBg = 'rgba(196,138,42,.2)' }
4192
5754
  else { stateTxt = locale === 'zh' ? '未下载' : 'Not downloaded'; stateBg = 'rgba(196,138,42,.2)' }
4193
5755
  var progress = dlActive || (isJs && dl.phase === 'done')
@@ -4201,14 +5763,17 @@ window.__ModuleLoader__.load({
4201
5763
  : (dl.phase === 'cancelled' ? t('dlCancelled')
4202
5764
  : (isJs && dl.phase === 'done' && !ready ? t('dlDone') + (locale === 'zh' ? '(缺运行库时需安装 @huggingface/transformers)' : ' (install @huggingface/transformers if runtime missing)')
4203
5765
  : (locale === 'zh' ? '下载进度' : 'Download progress'))))
4204
- var refreshSem = function () {
5766
+ // ★2026-09-20 移植(issue #89 / PR #95):局部 var refreshSem 遮蔽模块级
5767
+ // refreshSem(apply, onError) ⇒ 自递归爆栈被 .catch 吞 ⇒ 点「开始下载/取消」后
5768
+ // sem 永不刷新、轮询闸也永不启动。改名引用模块级函数。
5769
+ var refreshSemNow = function () {
4205
5770
  refreshSem(setSem)
4206
5771
  }
4207
5772
  var startDl = function () {
4208
- apiPost(API.semanticDownload, { action: 'start', mirror: mirror }).then(refreshSem).catch(function () {})
5773
+ apiPost(API.semanticDownload, { action: 'start', mirror: mirror }).then(refreshSemNow).catch(function () {})
4209
5774
  }
4210
5775
  var cancelDl = function () {
4211
- apiPost(API.semanticDownload, { action: 'cancel', mirror: mirror }).then(refreshSem).catch(function () {})
5776
+ apiPost(API.semanticDownload, { action: 'cancel', mirror: mirror }).then(refreshSemNow).catch(function () {})
4212
5777
  }
4213
5778
  return h('div', { style: { border: '1px solid color-mix(in srgb, var(--dam-accent, #2456c4) 40%, transparent)', borderRadius: '10px', padding: '10px 12px', marginBottom: '8px', fontSize: 'calc(11.5px * var(--dam-scale))', lineHeight: 1.6 } },
4214
5779
  h('div', { 'data-dam-row': '', style: { alignItems: 'center' } },
@@ -4300,8 +5865,20 @@ window.__ModuleLoader__.load({
4300
5865
  : null]),
4301
5866
  section('injection', sectionLabels.injection, [field(t('fInject'), h('input', { type: 'checkbox', checked: !!cfg.injectEnabled, onChange: function (e) { set('injectEnabled', e.target.checked) } }), t('fInjectHint')),
4302
5867
  field(t('fBudget'), h('input', { 'data-dam-input': '', type: 'number', min: 400, value: cfg.injectBudgetChars, onChange: function (e) { set('injectBudgetChars', Number(e.target.value) || 2400) } }), t('fBudgetHint')),
4303
- field(t('fNoteCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.noteCapacityChars === undefined ? 12000 : cfg.noteCapacityChars, onChange: function (e) { set('noteCapacityChars', Number(e.target.value) || 12000) } }), t('fNoteCapHint')),
4304
- field(t('fUserCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.userCapacityChars === undefined ? 12000 : cfg.userCapacityChars, onChange: function (e) { set('userCapacityChars', Number(e.target.value) || 12000) } }), t('fUserCapHint')),
5868
+ // 群反馈第 4 条:排除来源(每行一条)。textarea 便于粘贴多行路径;
5869
+ // 空行/纯空白在写回时被过滤,避免"看起来配了其实没配"。
5870
+ field(t('fExclude'), h('textarea', {
5871
+ 'data-dam-input': '', rows: 3,
5872
+ style: { width: '100%', resize: 'vertical', fontFamily: 'inherit' },
5873
+ value: (Array.isArray(cfg.injectExcludeSources) ? cfg.injectExcludeSources : []).join('\n'),
5874
+ placeholder: 'mem_0123456789abcdef0123456789abcdef\nD:\\ws\\.dsh-memory\\archive\\\nlog',
5875
+ onChange: function (e) {
5876
+ var lines = String(e.target.value || '').split('\n').map(function (x) { return x.trim() }).filter(Boolean)
5877
+ set('injectExcludeSources', lines)
5878
+ },
5879
+ }), t('fExcludeHint')),
5880
+ field(t('fNoteCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.noteCapacityChars === undefined ? 24000 : cfg.noteCapacityChars, onChange: function (e) { set('noteCapacityChars', Number(e.target.value) || 24000) } }), t('fNoteCapHint')),
5881
+ field(t('fUserCap'), h('input', { 'data-dam-input': '', type: 'number', min: 500, value: cfg.userCapacityChars === undefined ? 24000 : cfg.userCapacityChars, onChange: function (e) { set('userCapacityChars', Number(e.target.value) || 24000) } }), t('fUserCapHint')),
4305
5882
  field(t('fDays'), h('input', { 'data-dam-input': '', type: 'number', min: 1, max: 14, value: cfg.recentDaysInjected, onChange: function (e) { set('recentDaysInjected', Number(e.target.value) || 1) } }), t('fDaysHint')),
4306
5883
  field(t('fExtBudget'), h('input', { 'data-dam-input': '', type: 'number', min: 200, value: cfg.externalInjectionChars === undefined ? 1400 : cfg.externalInjectionChars, onChange: function (e) { set('externalInjectionChars', Number(e.target.value) || 1400) } }), t('fExtBudgetHint')),
4307
5884
  field(t('fSnapGap'), h('input', { 'data-dam-input': '', type: 'number', min: 0, max: 50, value: cfg.snapshotMinGapRounds === undefined ? 5 : cfg.snapshotMinGapRounds, onChange: function (e) { set('snapshotMinGapRounds', Number(e.target.value) || 5) } }), t('fSnapGapHint')),
@@ -4341,13 +5918,37 @@ window.__ModuleLoader__.load({
4341
5918
  locale === 'zh' ? '用于时段总结、问候语、自动沉淀、蒸馏等 subagent 功能;模型与思考强度(off/low/high/max)都经 DSH 的 agentOptions 下发,留空跟随路由与模型默认。保存后生效。' : 'For scheduled summaries, greetings, auto-consolidation and distillation subagents; model and reasoning effort (off/low/high/max) are delivered via DSH agentOptions. Empty follows routing and model defaults. Applies after saving.'),
4342
5919
  mdlOpen ? buildModelDrawer() : null,
4343
5920
  ]),
4344
- section('context', sectionLabels.context, [field(t('fHandoff'), h('input', { type: 'checkbox', checked: cfg.handoffEnabled !== false, onChange: function (e) { set('handoffEnabled', e.target.checked) } }), t('fHandoffHint')),
5921
+ // 2026-09-14 解耦:交接白板与自动接续各自独立成项(旧实现只在白板页有接续按钮,且 enabled 还被
5922
+ // handoffEnabled 二次与运算)。两者出厂默认均为 false(功能仍在测试期),这里与白板页读写完全相同的配置键。
5923
+ section('context', sectionLabels.context, [field(t('fHandoff'), h('input', { type: 'checkbox', 'data-dam-key': 'handoffEnabled', checked: cfg.handoffEnabled !== false, onChange: function (e) { set('handoffEnabled', e.target.checked) } }), t('fHandoffHint')),
5924
+ field(t('fAutoContinue'), h('input', { type: 'checkbox', 'data-dam-key': 'autoContinueEnabled', checked: cfg.autoContinueEnabled !== false, onChange: function (e) { set('autoContinueEnabled', e.target.checked) } }), t('fAutoContinueHint')),
4345
5925
  field(t('fHandoffPlan'), h('input', { 'data-dam-input': '', type: 'number', min: 200, value: cfg.handoffPlanChars === undefined ? 1200 : cfg.handoffPlanChars, onChange: function (e) { set('handoffPlanChars', Number(e.target.value) || 1200) } }), t('fHandoffPlanHint')),
4346
5926
  field(t('fHandoffLedger'), h('input', { 'data-dam-input': '', type: 'number', min: 100, value: cfg.handoffLedgerChars === undefined ? 800 : cfg.handoffLedgerChars, onChange: function (e) { set('handoffLedgerChars', Number(e.target.value) || 800) } }), t('fHandoffLedgerHint')),
4347
5927
  field(t('fWaterWindow'), h('input', { 'data-dam-input': '', type: 'number', min: 0, value: cfg.waterLevelWindowTokens === undefined ? 65536 : cfg.waterLevelWindowTokens, onChange: function (e) { set('waterLevelWindowTokens', Number(e.target.value) || 0) } }), t('fWaterWindowHint')),
4348
5928
  field(t('fWaterThreshold'), h('input', { 'data-dam-input': '', type: 'number', min: 0.1, max: 1.5, step: 0.05, value: cfg.waterLevelThreshold === undefined ? 0.75 : cfg.waterLevelThreshold, onChange: function (e) { set('waterLevelThreshold', Number(e.target.value) || 0.75) } }), t('fWaterThresholdHint')),
4349
5929
  field(t('fWaterAdvisory'), h('input', { type: 'checkbox', checked: cfg.waterLevelAdvisory !== false, onChange: function (e) { set('waterLevelAdvisory', e.target.checked) } }), t('fWaterAdvisoryHint')),
4350
5930
  field(t('fWaterAuto'), h('input', { type: 'checkbox', checked: cfg.waterLevelAutoHandoff !== false, onChange: function (e) { set('waterLevelAutoHandoff', e.target.checked) } }), t('fWaterAutoHint')),
5931
+ // WB-GRAPH 白板线一键切换(2026-09-16, board_mode_v1): 旧版白板(默认) / 新版看板(dsh-graph vendor
5932
+ // + sidecar + 遍历工具, 工具数 14→16)。开关解耦: 只管白板线; 切换需重启 dsh web 生效(工具注册在启动期)。
5933
+ field(locale === 'zh' ? '白板模式(旧版白板 / 新版看板)' : 'Board mode (legacy / graph)', (function () {
5934
+ var cur = cfg.boardMode === 'graph' ? 'graph' : 'legacy'
5935
+ // ★2026-09-16 修 BUG-3(设置页切换不落盘): 旧实现 `set('boardMode', m)` 只改**本地 React 态**
5936
+ // 然后 reload —— 配置从未写到服务端, 刷新后仍是旧值(界面回跳原档), 用户会判定"开关坏了"。
5937
+ // 正解: 走与接续面板同一个写入路径 `saveConfigPatch(API.config, patch)`(见 :999 定义),
5938
+ // 写盘成功后再 reload; 失败则在界面提示, 不静默假装成功。
5939
+ // 同时满足用户硬性偏好: 开关类改动必须**即时回显**(写盘后立即 reload 呈现新档)。
5940
+ function pick(m) {
5941
+ set('boardMode', m) // 先本地回显(即时反馈)
5942
+ saveConfigPatch({ boardMode: m }, {
5943
+ onSaved: function () { window.setTimeout(function () { window.location.reload() }, 350) },
5944
+ onError: function (e) { setErr(String((e && e.message) || e || 'save failed')) },
5945
+ })
5946
+ }
5947
+ return h('div', { 'data-dam-row': '' },
5948
+ h('button', { 'data-dam-btn': '', 'data-dam-key': 'boardMode', style: { fontWeight: cur === 'legacy' ? 700 : 400, opacity: cur === 'legacy' ? 1 : 0.65 }, onClick: function () { if (cur !== 'legacy') pick('legacy') } }, locale === 'zh' ? '旧版白板' : 'Legacy board'),
5949
+ h('button', { 'data-dam-btn': '', 'data-dam-key': 'boardModeGraph', style: { marginLeft: '6px', fontWeight: cur === 'graph' ? 700 : 400, opacity: cur === 'graph' ? 1 : 0.65 }, onClick: function () { if (cur !== 'graph') pick('graph') } }, locale === 'zh' ? '新版看板(dsh-graph)' : 'Graph board (dsh-graph)'))
5950
+ })(),
5951
+ locale === 'zh' ? '一键切换白板形态。默认「旧版白板」=一切行为不变;切到「新版看板」后启用 dsh-graph 看板、白板结构化索引(handoff/index.json)与两个遍历工具(memory_expand / memory_trace,工具数 14→16)。切换后需重启 dsh web 生效;再点回旧版即完全回滚。' : 'One-click switch for the board form. Default "Legacy board" keeps everything unchanged; "Graph board" enables the dsh-graph kanban, the structured handoff index (handoff/index.json) and two traversal tools (memory_expand / memory_trace, tool count 14→16). Restart dsh web after switching; switch back to fully roll back.'),
4351
5952
  field(locale === 'zh' ? '子代理痕迹回收' : 'Subagent trace recycle', h('input', { type: 'checkbox', checked: cfg.subagentGcEnabled !== false, onChange: function (e) { set('subagentGcEnabled', e.target.checked) } }),
4352
5953
  locale === 'zh' ? '本插件的一次性子代理(自动沉淀 / 时段总结 / 问候 / 蒸馏)每跑一次都会在 ~/.dsh/sessions 留下一个持久化会话;数量上千后会话列表会明显变慢。开启后任务一结束就把这些痕迹移入 ~/.dsh/subagent-gc-backup 备份(可回滚),不影响子代理结果。默认开。' : 'Every one-shot subagent this plugin spawns (auto-consolidation, scheduled summary, greeting, distillation) leaves a persisted session under ~/.dsh/sessions; hundreds of them slow the session list down. When on, each trace is moved into ~/.dsh/subagent-gc-backup right after the task ends (reversible), never affecting the subagent result. On by default.'),
4353
5954
  field(locale === 'zh' ? '兜底回收保留天数' : 'Fallback recycle keep days', h('input', { 'data-dam-input': '', type: 'number', min: 0, value: cfg.subagentGcKeepDays === undefined ? 3 : cfg.subagentGcKeepDays, onChange: function (e) { set('subagentGcKeepDays', Number(e.target.value) || 0) } }),
@@ -4469,7 +6070,8 @@ window.__ModuleLoader__.load({
4469
6070
  // v0.1.38:旧 first 卡已删除 → 全新安装直接完整欢迎向导;老用户按 seen/向导完成态补大更新。
4470
6071
  // 抽成具名函数便于状态机单测(startup-dispatch)。
4471
6072
  var MAJOR_TOUR_KEY = 'dsh-auto-memory.majorTourV130'
4472
- function dispatchStartupDialog(d) {
6073
+ function dispatchStartupDialog(d, cfg) {
6074
+ var allowTour = welcomeAutoAllowedPre(cfg)
4473
6075
  if (d && d.current) {
4474
6076
  try {
4475
6077
  var seen = localStorage.getItem('dsh-auto-memory.seenVersion')
@@ -4478,28 +6080,31 @@ window.__ModuleLoader__.load({
4478
6080
  var firstDone = localStorage.getItem('dsh-auto-memory.firstRunDone')
4479
6081
  // 0.1.38:旧的 first 欢迎卡已删除(内容被 welcomeTour 完整覆盖,且小卡形态无 ✕ 关闭)。
4480
6082
  // 全新安装直接完整欢迎向导(分步功能开关+引擎检测/下载引导)。
6083
+ // issue#40:三个自动弹出分支都必须过 allowTour 配置闸 —— 否则 welcomeTourEnabled=false 形同虚设。
4481
6084
  var freshInstall = !seen && !firstDone && !wizDone
4482
6085
  var isExisting = !!seen || !!firstDone || !!wizDone
4483
- if (freshInstall) {
6086
+ if (allowTour && freshInstall) {
4484
6087
  openDialog({ kind: 'welcomeTour' })
4485
6088
  try { localStorage.setItem(MAJOR_TOUR_KEY, '1') } catch (eF) {}
4486
6089
  return
4487
6090
  }
4488
6091
  // 老用户(有使用痕迹)seen < 0.1.30(大更新前版本)或从未看过向导 → 补一次完整欢迎向导
4489
6092
  // (含引擎下载引导;0.1.29→0.1.34 直升即使曾看过旧版也重放,确保引导不漏)
4490
- if (isExisting && !wizDone && (!seen || cmpVersion(seen, '0.1.30') < 0)) {
6093
+ if (allowTour && isExisting && !wizDone && (!seen || cmpVersion(seen, '0.1.30') < 0)) {
4491
6094
  openDialog({ kind: 'welcomeTour' })
4492
6095
  try { localStorage.setItem(MAJOR_TOUR_KEY, '1') } catch (eM) {}
4493
6096
  return
4494
6097
  }
4495
- if (majorPending && isExisting && !wizDone) {
6098
+ if (allowTour && majorPending && isExisting && !wizDone) {
4496
6099
  openDialog({ kind: 'welcomeTour' })
4497
6100
  try { localStorage.setItem(MAJOR_TOUR_KEY, '1') } catch (eM) {}
4498
6101
  return
4499
6102
  }
4500
6103
  if (seen && seen !== d.current) {
4501
- // 只弹 2.1.0 大更新卡(历次小更新已总结并入),不逐个弹历史版本——大更新内容才是要传达的
4502
- var bigKey = '2.1.0'
6104
+ // ★2026-09-17 修复:bigKey 原是硬编码 '2.1.0' ⇒ 老用户升级到 3.0.0 时弹的仍是 2.1.0 的
6105
+ // 说明卡,本版 changelog 根本送达不了(违反「用户读得到的弹窗 changelog」要求)。
6106
+ // 改为**动态取当前版本**:只要 CHANGELOG 里有本版条目就弹本版,以后每个大版本自动正确。
6107
+ var bigKey = (CHANGELOG[d.current] ? d.current : '2.1.0')
4503
6108
  if (CHANGELOG[bigKey]) {
4504
6109
  openDialog({ kind: 'update', versions: [{ version: bigKey, items: CHANGELOG[bigKey] }], currentVersion: d.current })
4505
6110
  try { localStorage.setItem('dsh-auto-memory.seenVersion', d.current) } catch (e3b) {}
@@ -4516,7 +6121,39 @@ window.__ModuleLoader__.load({
4516
6121
  } catch (e) {}
4517
6122
  }
4518
6123
  }
4519
- apiGet(API.updateCheck).then(dispatchStartupDialog).catch(function () {})
6124
+ // issue#40:不得与 update-check 抢跑 —— 配置未知时(Null)一律**不自动播放**,等配置到达再分发。
6125
+ var startupEpoch = tourNavigationEpoch
6126
+ Promise.all([
6127
+ apiGet(API.updateCheck).catch(function () { return null }),
6128
+ loadWelcomeConfigPre(5000),
6129
+ ]).then(function (pair) {
6130
+ // 配置闸的清理动作会推进 epoch;此时仍要送达版本通知,但向导必须保持关闭。
6131
+ var cfg = pair[1]
6132
+ if (tourNavigationEpoch !== startupEpoch) cfg = { welcomeTourEnabled: false }
6133
+ dispatchStartupDialog(pair[0], cfg)
6134
+ }).catch(function () {})
6135
+ // 会话切换 / 浏览器前进后退 都视为"用户已离开向导"⇒ 撤下,避免浮层跨页面残留(移动端体感:关不掉)。
6136
+ if (typeof window['dsh-auto-memory.disposeTourNavigation'] === 'function') window['dsh-auto-memory.disposeTourNavigation']()
6137
+ var lastTourSession = currentSessionIdClient()
6138
+ var tourSessionDispose = null
6139
+ function tourNavigated() { dismissWelcomeTourPre('navigation') }
6140
+ try {
6141
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
6142
+ tourSessionDispose = sessions.list.subscribe(function () {
6143
+ var sid = currentSessionIdClient()
6144
+ if (sid !== lastTourSession) { lastTourSession = sid; dismissWelcomeTourPre('navigation') }
6145
+ })
6146
+ }
6147
+ window.addEventListener('popstate', tourNavigated)
6148
+ window.addEventListener('hashchange', tourNavigated)
6149
+ } catch (_) {}
6150
+ window['dsh-auto-memory.disposeTourNavigation'] = function () {
6151
+ if (typeof tourSessionDispose === 'function') tourSessionDispose()
6152
+ window.removeEventListener('popstate', tourNavigated)
6153
+ window.removeEventListener('hashchange', tourNavigated)
6154
+ tourSessionDispose = null
6155
+ }
6156
+ if (typeof ctx !== 'undefined' && ctx && typeof ctx.effect === 'function') ctx.effect(function () { return window['dsh-auto-memory.disposeTourNavigation'] }, 'auto-memory:tour-navigation')
4520
6157
 
4521
6158
  // 动态通知(发布者→用户:重大 bug 提醒,不依赖发版):启动检查 + 每小时刷新;按 id 去重,urgent 优先
4522
6159
  function checkNotices() {
@@ -4599,6 +6236,37 @@ window.__ModuleLoader__.load({
4599
6236
  surfaceDisposers.push(slots.inject('settings.section', function () {
4600
6237
  return slots.register({ name: 'settings.section', id: 'auto-memory', order: 25, label: t('autoMemory') + ' (pre)' }, function (props) { return h(SettingsPage, { close: props && props.close }) })
4601
6238
  }))
6239
+ // 2026-09-16「双承载面」: 会话页顶栏再挂一个**整页白板看板**(与侧边面板共存)。
6240
+ // 面板 440px 装不下矩阵, 整页 ~1000px+ 可以 ⇒ 两者按容器宽度各用其形态。
6241
+ // 与 boardMode 闸门解耦: 组件内部自己取数, 非 graph 档拿到 enabled:false 就显示提示,
6242
+ // 不改变 legacy 档下顶栏是否出现该 Tab 的行为(那由宿主/开关层决定, 本处不加判断)。
6243
+ surfaceDisposers.push(slots.inject('conversation.view', function () {
6244
+ return slots.register(
6245
+ { name: 'conversation.view', id: 'auto-memory-kanban', order: 80, label: function () { return locale === 'zh' ? '白板看板' : 'Whiteboard' } },
6246
+ function (props) { return h(KanbanView, props || {}) },
6247
+ )
6248
+ }))
6249
+ // 2026-09-21「白板画布」**暂时隐藏**(用户拍板:功能未达可用标准,先发版收尾)。
6250
+ //
6251
+ // 为什么不删代码: 画布本身是完整实现(清洗层 + 分列布局 + 平移缩放 + 侧栏),
6252
+ // 只是**数据源受限** —— 实测 D:\dsh-auto-memory 下 880 张卡、189 篇文档,
6253
+ // 当前布局是「泳道分列 + 每列上限 14 + 折叠」, 但它依赖 kanban-board 在当前
6254
+ // 会话下能解析出**正确的工作区**; 实测 sessionId 解析失败时 API 返回
6255
+ // enabled:true 但 **ws 为空 + 0 张卡** ⇒ 画布显示"暂无内容"。
6256
+ // ⇒ 保留代码 + 默认关闭, 待工作区解析路径稳定后一行开启。
6257
+ //
6258
+ // 开启方式: 把下面的 false 改成 true(或设为 localStorage['dam-wbg-enabled']='1')。
6259
+ var WBG_ENABLED = (function () {
6260
+ try { return localStorage.getItem('dam-wbg-enabled') === '1' } catch (e) { return false }
6261
+ })()
6262
+ if (WBG_ENABLED) {
6263
+ surfaceDisposers.push(slots.inject('conversation.view', function () {
6264
+ return slots.register(
6265
+ { name: 'conversation.view', id: 'auto-memory-graph', order: 81, label: function () { return locale === 'zh' ? '白板画布' : 'Graph' } },
6266
+ function (props) { return h(WhiteboardGraphView, props || {}) },
6267
+ )
6268
+ }))
6269
+ }
4602
6270
  } catch (e) {
4603
6271
  console.warn('[dsh-auto-memory] slot registration failed', e)
4604
6272
  }