dsh-instruction-memory 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -118,6 +118,61 @@ window.__ModuleLoader__.load({
118
118
  border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.14)); background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); }
119
119
  .im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
120
120
  background:transparent; border-radius:7px; padding:2px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
121
+ /* ---- F13 配置集 ---- */
122
+ .im-sets { display:flex; flex-direction:column; gap:7px; padding:10px 12px; border-radius:10px;
123
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03)); }
124
+ .im-sets-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
125
+ .im-select-set { max-width:220px; }
126
+ .im-active-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-brand-primary,#3b6cff); }
127
+ .im-inline-form { display:flex; align-items:center; gap:8px; flex-wrap:wrap;
128
+ padding:8px 10px; border-radius:8px; background:var(--dsw-alias-bg-base,#fff);
129
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); }
130
+ .im-inline-form .im-input { flex:1; min-width:140px; }
131
+ /* ---- F14 版本历史 ---- */
132
+ .im-history { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
133
+ background:var(--dsw-alias-bg-layer-1,#fff); }
134
+ .im-history-item { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:7px 9px; margin-top:7px;
135
+ border-radius:8px; border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
136
+ .im-history-item-current { border-color:var(--dsw-alias-brand-primary,#3b6cff); }
137
+ .im-history-sub { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); }
138
+ .im-list-scroll { max-height:260px; overflow:auto; }
139
+ /* ---- F16 源码模式 ---- */
140
+ .im-source { display:flex; flex-direction:column; gap:8px; }
141
+ .im-textarea-source { min-height:340px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
142
+ font-size:11.5px; line-height:1.6; white-space:pre; }
143
+ .im-source-ok { font-size:12px; color:var(--dsw-alias-state-success-primary,#18a058); }
144
+ /* ---- F17 本地预演 ---- */
145
+ .im-sim-result { display:flex; flex-direction:column; gap:6px; margin-top:6px; }
146
+ .im-sim-item { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:6px 9px; border-radius:8px;
147
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
148
+ .im-sim-note { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); }
149
+ .im-tag-sim-high { color:var(--dsw-alias-state-error-primary,#d94838); border-color:currentColor; }
150
+ .im-tag-sim-mid { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
151
+ .im-tag-sim-low { color:var(--dsw-alias-label-secondary,#8a9099); border-color:currentColor; }
152
+ /* ---- F18 备份位置 ---- */
153
+ .im-backup { display:flex; flex-direction:column; gap:8px; }
154
+ .im-backup-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
155
+ .im-backup-row .im-input { flex:1; min-width:200px; }
156
+ /* ---- F12 诊断与安全状态 ---- */
157
+ .im-hostwarn { display:flex; gap:8px; align-items:flex-start; padding:9px 12px; border-radius:8px; font-size:12px;
158
+ color:var(--dsw-alias-state-warn-primary,#d97706); border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
159
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); font-weight:500; }
160
+ .im-diag { display:flex; flex-direction:column; gap:8px; }
161
+ .im-diag-group { display:flex; flex-direction:column; gap:3px; padding:8px 10px; border-radius:8px;
162
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
163
+ .im-diag-sub { color:var(--dsw-alias-label-secondary,#8a9099); font-size:11.5px; }
164
+ .im-diag-fallback { display:flex; flex-direction:column; gap:5px; }
165
+ .im-diag-fallback .im-textarea { min-height:170px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; }
166
+ /* ---- F7 临时静音 ---- */
167
+ .im-master-col { display:flex; flex-direction:column; align-items:flex-end; gap:6px; }
168
+ .im-pause-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
169
+ .im-pause-menu { display:flex; flex-direction:column; gap:4px; padding:8px 10px; border-radius:8px;
170
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-base,#fff);
171
+ box-shadow:0 4px 14px rgba(0,0,0,.1); }
172
+ .im-pause-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; justify-content:flex-end;
173
+ padding:6px 10px; border-radius:8px; font-size:12px;
174
+ border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
175
+ background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
121
176
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
122
177
  `
123
178
 
@@ -260,6 +315,128 @@ window.__ModuleLoader__.load({
260
315
  'undo.restore': '撤销删除',
261
316
  'undo.dismiss': '知道了',
262
317
  'undo.restored': '已恢复刚删除的指令',
318
+ // ---- F13 多情境配置集 ----
319
+ 'sets.section': '配置集',
320
+ 'sets.note': '把记忆分成互不相干的几套(如「工作」「个人」「某客户」)。注入只使用当前激活的一套;切换只改变注入内容,不会移动或删除任何文本。',
321
+ 'sets.total': '全部配置集合计 {total} / {max} 条指令',
322
+ 'sets.activeHint': '当前注入「{name}」',
323
+ 'sets.create': '+ 新建',
324
+ 'sets.rename': '重命名',
325
+ 'sets.duplicate': '复制',
326
+ 'sets.delete': '删除',
327
+ 'sets.nameLabel': '配置集名称',
328
+ 'sets.namePlaceholder': '例如:工作 / 个人 / 某客户',
329
+ 'sets.confirm': '确定',
330
+ 'sets.nameEmpty': '请先填写配置集名称',
331
+ 'sets.itemMeta': '{entries} 条指令 · {profiles} 个形象',
332
+ 'sets.activeBadge': '使用中',
333
+ 'sets.switch': '切换',
334
+ 'sets.switchHint': '切换到该配置集:注入内容立即变更',
335
+ 'sets.copySuffix': ' 副本',
336
+ 'sets.deleteConfirm': '确定删除配置集「{name}」?其中的 {entries} 条指令与 {profiles} 个用户形象会一并删除,且无法撤销。',
337
+ 'sets.deleteActive': '当前激活的配置集不能删除;请先切换到其他配置集。',
338
+ 'sets.draftsClosed': '已关闭切换前打开的编辑草稿',
339
+ // ---- F12 诊断与安全状态 ----
340
+ 'diag.section': '诊断信息',
341
+ 'diag.show': '展开诊断',
342
+ 'diag.hide': '收起诊断',
343
+ 'diag.copy': '复制诊断信息',
344
+ 'diag.copied': '诊断信息已复制到剪贴板',
345
+ 'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
346
+ 'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
347
+ 'diag.groupStorage': '存储',
348
+ 'diag.groupInjection': '注入',
349
+ 'diag.groupBackup': '备份与历史',
350
+ 'diag.groupIssues': '错误与警告',
351
+ 'diag.none': '(无)',
352
+ 'diag.storagePath': '存储文件',
353
+ 'diag.onDisk': '存储文件已在磁盘',
354
+ 'diag.loadedFrom': '载入来源',
355
+ 'diag.savedAt': '最近保存',
356
+ 'diag.rev': '修订号(rev)',
357
+ 'diag.registered': '注入已注册',
358
+ 'diag.yes': '是',
359
+ 'diag.no': '否',
360
+ 'diag.pulls': '注入拉取次数(自本次插件启动计数)',
361
+ 'diag.chars': '当前注入字符数 / 上限',
362
+ 'diag.backupDir': '备份位置',
363
+ 'diag.backupDefault': '默认(与存储文件同目录)',
364
+ 'diag.backupPath': '覆盖副本(.bak)',
365
+ 'diag.historyDir': '版本历史目录',
366
+ 'diag.historyCount': '历史快照数',
367
+ 'diag.schemaFile': 'Schema 文件',
368
+ 'diag.issueStorage': '存储错误',
369
+ 'diag.issueWarning': '存储警告',
370
+ 'diag.issueHistory': '历史错误',
371
+ 'diag.issueSection': '注入错误',
372
+ // ---- F7 临时静音 ----
373
+ 'mute.menu': '暂停注入…',
374
+ 'mute.30m': '30 分钟',
375
+ 'mute.1h': '1 小时',
376
+ 'mute.today': '到今天结束',
377
+ 'mute.resume': '立即恢复',
378
+ 'mute.active': '注入已暂停,剩余 {remaining}',
379
+ 'mute.hint': '暂停期间不注入任何指令或用户形象,到期后自动恢复;此状态跨重启保留。',
380
+ 'mute.remainingMs': '{m} 分 {s} 秒',
381
+ 'mute.remainingH': '{h} 小时 {m} 分',
382
+ // ---- F14 版本历史 ----
383
+ 'history.section': '版本历史',
384
+ 'history.subtitle': '每次保存都会留一份带时间戳的快照(每台设备最多保留 {max} 份,逐出最旧的)。可在此查看并一键回滚。',
385
+ 'history.show': '查看版本历史',
386
+ 'history.hide': '收起',
387
+ 'history.count': '共 {count} 份',
388
+ 'history.loading': '正在读取…',
389
+ 'history.empty': '还没有历史快照(第一次保存后出现)。',
390
+ 'history.itemMeta': '{entries} 条指令 / {profiles} 个形象 · {size} KB',
391
+ 'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
392
+ 'history.foreign': '来自其他设备({device})',
393
+ 'history.restore': '回滚到此版本',
394
+ 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
395
+ 'history.delete': '删除',
396
+ 'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
397
+ 'history.dir': '快照目录:{dir}',
398
+ // ---- F16 源码模式 ----
399
+ 'source.mode': '源码模式',
400
+ 'source.exit': '退出源码模式',
401
+ 'source.section': '源码模式(整份 JSON)',
402
+ 'source.subtitle': '直接编辑整份存储文件(与 memory.json 内容一致)。保存时走与手改文件相同的校验与披露,并带 rev 冲突保护;保存后立即生效。',
403
+ 'source.save': '保存源码',
404
+ 'source.reload': '重新载入最新内容',
405
+ 'source.valid': 'JSON 语法正确:{entries} 条指令 / {profiles} 个形象 / {sets} 个配置集',
406
+ 'source.invalid': 'JSON 语法错误:{detail}',
407
+ 'source.empty': '内容为空',
408
+ 'source.notObject': '顶层必须是一个 JSON 对象',
409
+ 'source.keepDraft': '保存被拒绝,源码草稿未丢失;可修改后重试,或点「重新载入最新内容」放弃本地修改。',
410
+ 'source.reloadConfirm': '重新载入会丢弃当前编辑的源码草稿,确定吗?',
411
+ 'source.editorOpen': '请先关闭正在编辑的条目或形象,再进入源码模式',
412
+ 'source.truncatedInput': '源码过长({size} 万字符),已停止实时校验;保存时仍会按上限校验。',
413
+ // ---- F17 本地预演 ----
414
+ 'sim.title': '按需指令本地预演',
415
+ 'sim.toggle': '本地预演',
416
+ 'sim.hide': '收起预演',
417
+ 'sim.disclaimer': '本地预演 ≠ 模型的真实判断:这里只用字面关键词匹配各条「按需」指令的适用场景/标题/内容,帮你把「适用场景」写清楚、预判取舍。它不参与注入,也不代表模型实际会不会采纳。',
418
+ 'sim.placeholder': '输入一句当前任务描述,例如:帮我改一下这个配置文件',
419
+ 'sim.hint': '输入任务描述后,这里会列出字面相关度较高的「按需」指令;结果仅供自查。',
420
+ 'sim.levelHigh': '预演·高',
421
+ 'sim.levelMid': '预演·中',
422
+ 'sim.levelLow': '预演·低',
423
+ 'sim.matched': '字面命中:{terms}',
424
+ 'sim.noWhen': '未填写「适用场景」,建议补充',
425
+ 'sim.none': '没有「按需」指令与这段描述存在字面匹配。',
426
+ 'sim.onlyAuto': '仅「按需」指令参与预演;「始终」指令无条件生效,不参与匹配,也不受此结果影响。',
427
+ 'sim.summary': '共 {total} 条按需指令,字面命中 {hit} 条',
428
+ 'sim.omitted': '本轮因超出字数上限未注入',
429
+ // ---- F18 备份位置 ----
430
+ 'backup.section': '备份位置',
431
+ 'backup.label': '自定义备份位置(绝对路径;留空 = 与存储文件同目录)',
432
+ 'backup.placeholder': '例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory',
433
+ 'backup.apply': '应用',
434
+ 'backup.exportToDir': '导出到备份目录',
435
+ 'backup.hint': '备份位置决定「版本历史」目录与「导出到备份目录」的去向,并额外保留一份 memory.json.bak;存储文件本身(及其跨进程锁)不会移动。它是本机设置,不会随导出文件带到别的机器。',
436
+ 'backup.multiDevice': '多设备建议:把备份位置指向本地云盘文件夹,只同步「导出文件」与「history 快照」;不要用云盘同步存储目录本身,两台机器同时写入会互相覆盖。',
437
+ 'action.restore': '从备份恢复…',
438
+ 'restore.hint': '用导出的备份文件整体替换当前记忆(导入只做合并,无法还原整份)',
439
+ 'restore.confirm': '确定用所选备份文件整体替换当前记忆?现有内容仍保留在 memory.json.bak 与版本历史中;配置集、条目与用户形象都会被文件内容替换(备份位置等本机设置保持不变)。',
263
440
  },
264
441
  en: {
265
442
  'section.label': 'Instruction Memory',
@@ -389,6 +566,128 @@ window.__ModuleLoader__.load({
389
566
  'undo.restore': 'Undo delete',
390
567
  'undo.dismiss': 'Dismiss',
391
568
  'undo.restored': 'The deleted instruction was restored',
569
+ // ---- F13 instruction sets ----
570
+ 'sets.section': 'Instruction sets',
571
+ 'sets.note': 'Split memory into independent sets (e.g. Work / Personal / a client). Only the active set is injected; switching changes what is injected and never moves or deletes any text.',
572
+ 'sets.total': '{total} of {max} instructions across all sets',
573
+ 'sets.activeHint': 'Injecting “{name}”',
574
+ 'sets.create': '+ New',
575
+ 'sets.rename': 'Rename',
576
+ 'sets.duplicate': 'Duplicate',
577
+ 'sets.delete': 'Delete',
578
+ 'sets.nameLabel': 'Set name',
579
+ 'sets.namePlaceholder': 'e.g. Work / Personal / Acme client',
580
+ 'sets.confirm': 'OK',
581
+ 'sets.nameEmpty': 'Enter a set name first',
582
+ 'sets.itemMeta': '{entries} instructions · {profiles} personas',
583
+ 'sets.activeBadge': 'In use',
584
+ 'sets.switch': 'Switch',
585
+ 'sets.switchHint': 'Switch to this set: the injected content changes immediately',
586
+ 'sets.copySuffix': ' copy',
587
+ 'sets.deleteConfirm': 'Delete the set “{name}”? Its {entries} instructions and {profiles} personas are deleted with it. This cannot be undone.',
588
+ 'sets.deleteActive': 'The active set cannot be deleted; switch to another set first.',
589
+ 'sets.draftsClosed': 'The editor draft open before the switch was closed',
590
+ // ---- F12 diagnostics & security panel ----
591
+ 'diag.section': 'Diagnostics',
592
+ 'diag.show': 'Show diagnostics',
593
+ 'diag.hide': 'Hide diagnostics',
594
+ 'diag.copy': 'Copy diagnostics',
595
+ 'diag.copied': 'Diagnostics copied to the clipboard',
596
+ 'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
597
+ 'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
598
+ 'diag.groupStorage': 'Storage',
599
+ 'diag.groupInjection': 'Injection',
600
+ 'diag.groupBackup': 'Backup & history',
601
+ 'diag.groupIssues': 'Errors & warnings',
602
+ 'diag.none': '(none)',
603
+ 'diag.storagePath': 'Store file',
604
+ 'diag.onDisk': 'Store file on disk',
605
+ 'diag.loadedFrom': 'Loaded from',
606
+ 'diag.savedAt': 'Last saved',
607
+ 'diag.rev': 'Revision (rev)',
608
+ 'diag.registered': 'Section registered',
609
+ 'diag.yes': 'yes',
610
+ 'diag.no': 'no',
611
+ 'diag.pulls': 'Prompt pulls (counted since this plugin started)',
612
+ 'diag.chars': 'Injected chars / budget',
613
+ 'diag.backupDir': 'Backup location',
614
+ 'diag.backupDefault': 'default (next to the store file)',
615
+ 'diag.backupPath': 'Rolling copy (.bak)',
616
+ 'diag.historyDir': 'History directory',
617
+ 'diag.historyCount': 'History snapshots',
618
+ 'diag.schemaFile': 'Schema file',
619
+ 'diag.issueStorage': 'Storage error',
620
+ 'diag.issueWarning': 'Storage warning',
621
+ 'diag.issueHistory': 'History error',
622
+ 'diag.issueSection': 'Injection error',
623
+ // ---- F7 temporary mute ----
624
+ 'mute.menu': 'Pause injection…',
625
+ 'mute.30m': '30 minutes',
626
+ 'mute.1h': '1 hour',
627
+ 'mute.today': 'Until the end of today',
628
+ 'mute.resume': 'Resume now',
629
+ 'mute.active': 'Injection paused — {remaining} left',
630
+ 'mute.hint': 'While paused, neither instructions nor personas are injected. It resumes automatically and survives a restart.',
631
+ 'mute.remainingMs': '{m}m {s}s',
632
+ 'mute.remainingH': '{h}h {m}m',
633
+ // ---- F14 version history ----
634
+ 'history.section': 'Version history',
635
+ 'history.subtitle': 'Every save leaves a timestamped snapshot ({max} kept per device, oldest evicted first). Review them here and roll back with one click.',
636
+ 'history.show': 'Show version history',
637
+ 'history.hide': 'Hide',
638
+ 'history.count': '{count} snapshots',
639
+ 'history.loading': 'Loading…',
640
+ 'history.empty': 'No snapshots yet (the first save creates one).',
641
+ 'history.itemMeta': '{entries} instructions / {profiles} personas · {size} KB',
642
+ 'history.itemMetaLite': '{size} KB (file too large to count items)',
643
+ 'history.foreign': 'From another device ({device})',
644
+ 'history.restore': 'Roll back to this',
645
+ 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
646
+ 'history.delete': 'Delete',
647
+ 'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
648
+ 'history.dir': 'Snapshot folder: {dir}',
649
+ // ---- F16 source mode ----
650
+ 'source.mode': 'Source mode',
651
+ 'source.exit': 'Exit source mode',
652
+ 'source.section': 'Source mode (whole JSON)',
653
+ 'source.subtitle': 'Edit the whole store file (identical to memory.json). Saving runs the same validation and disclosures as a hand edit, carries rev conflict protection, and takes effect immediately.',
654
+ 'source.save': 'Save source',
655
+ 'source.reload': 'Reload latest',
656
+ 'source.valid': 'Valid JSON: {entries} instructions / {profiles} personas / {sets} sets',
657
+ 'source.invalid': 'Invalid JSON: {detail}',
658
+ 'source.empty': 'The content is empty',
659
+ 'source.notObject': 'The top level must be a JSON object',
660
+ 'source.keepDraft': 'The save was refused and your draft is intact; fix it and retry, or click “Reload latest” to discard local edits.',
661
+ 'source.reloadConfirm': 'Reloading discards the source draft you are editing. Continue?',
662
+ 'source.editorOpen': 'Close the open entry/persona editor before entering source mode',
663
+ 'source.truncatedInput': 'The source is very long ({size}0k characters); live validation is paused. Saving still enforces the limit.',
664
+ // ---- F17 local preview ----
665
+ 'sim.title': 'Local preview of on-demand rules',
666
+ 'sim.toggle': 'Local preview',
667
+ 'sim.hide': 'Hide preview',
668
+ 'sim.disclaimer': 'A local preview is NOT the model’s actual judgement: it only matches literal keywords against each on-demand rule’s scenario/title/content to help you sharpen the scenario field and predict trade-offs. It does not take part in injection and says nothing about whether the model would actually follow the rule.',
669
+ 'sim.placeholder': 'Describe the current task, e.g. help me edit this config file',
670
+ 'sim.hint': 'Type a task description to list the on-demand rules with the strongest literal overlap; the result is for your own inspection.',
671
+ 'sim.levelHigh': 'Preview · high',
672
+ 'sim.levelMid': 'Preview · medium',
673
+ 'sim.levelLow': 'Preview · low',
674
+ 'sim.matched': 'Literal matches: {terms}',
675
+ 'sim.noWhen': 'No scenario text — consider adding one',
676
+ 'sim.none': 'No on-demand rule overlaps this description literally.',
677
+ 'sim.onlyAuto': 'Only on-demand rules take part in the preview; always-on rules apply unconditionally and are unaffected by this result.',
678
+ 'sim.summary': '{total} on-demand rules, {hit} with a literal match',
679
+ 'sim.omitted': 'Not injected this round (over the size limit)',
680
+ // ---- F18 backup location ----
681
+ 'backup.section': 'Backup location',
682
+ 'backup.label': 'Custom backup location (absolute path; empty = next to the store file)',
683
+ 'backup.placeholder': 'e.g. D:\\cloud\\dsh-memory or /home/me/cloud/dsh-memory',
684
+ 'backup.apply': 'Apply',
685
+ 'backup.exportToDir': 'Export to backup folder',
686
+ 'backup.hint': 'This folder holds the version-history directory and the “Export to backup folder” target, and keeps one extra memory.json.bak. The store file itself (and its cross-process lock) never moves. It is machine-local and is not carried to another machine by an export.',
687
+ 'backup.multiDevice': 'Multi-device tip: point this at a local cloud-sync folder and let only the export files and history snapshots sync. Do not sync the store directory itself — two machines writing at once would overwrite each other.',
688
+ 'action.restore': 'Restore from backup…',
689
+ 'restore.hint': 'Replace the whole memory with an exported backup file (import only merges, so it cannot restore a whole store)',
690
+ 'restore.confirm': 'Replace the whole memory with the selected backup file? The current content stays in memory.json.bak and in the version history; sets, instructions and personas are all replaced by the file (machine-local settings such as the backup location are kept).',
392
691
  },
393
692
  }
394
693
 
@@ -733,6 +1032,216 @@ window.__ModuleLoader__.load({
733
1032
  return { budget, used, omitted, truncated, percent, tokens }
734
1033
  }
735
1034
 
1035
+ /**
1036
+ * F12: serialize a diagnostic snapshot into plain text for an issue report.
1037
+ * Pinned as a pure export so verify.mjs can hold the contract: the dump
1038
+ * carries the operational fields (store shape, storage, injection,
1039
+ * security, load origin) plus the reporter's environment, and NEVER
1040
+ * carries user text — no entry titles, no entry or persona bodies. Paths
1041
+ * (including the absolute backup directory) are machine-local but are the
1042
+ * whole point of a support dump, so they stay.
1043
+ */
1044
+ function diagnosticsText(view, env) {
1045
+ const sec = (root, name) => (root !== null && typeof root === 'object'
1046
+ && root[name] !== null && typeof root[name] === 'object' ? root[name] : {})
1047
+ const data = sec(view, 'data')
1048
+ const storage = sec(view, 'storage')
1049
+ const injection = sec(view, 'injection')
1050
+ const security = sec(view, 'security')
1051
+ const diagnostics = sec(view, 'diagnostics')
1052
+ const environment = env !== null && typeof env === 'object' ? env : {}
1053
+ const v = (value) => (value === null || value === undefined ? '' : String(value))
1054
+ const count = (value) => (Array.isArray(value) ? String(value.length) : '0')
1055
+ return [
1056
+ 'dsh-instruction-memory diagnostics',
1057
+ '--- store ---',
1058
+ 'version: ' + v(data.version),
1059
+ 'rev: ' + v(data.rev),
1060
+ 'enabled: ' + v(data.enabled),
1061
+ 'budgetChars: ' + v(data.budgetChars),
1062
+ 'disabledUntil: ' + v(data.disabledUntil),
1063
+ 'sets: ' + count(data.sets),
1064
+ 'entries(active set): ' + count(data.entries),
1065
+ 'profiles(active set): ' + count(data.profiles),
1066
+ '--- storage ---',
1067
+ 'path: ' + v(storage.path),
1068
+ 'onDisk: ' + v(storage.onDisk),
1069
+ 'loadedFrom: ' + v(diagnostics.loadedFrom),
1070
+ 'rev: ' + v(diagnostics.rev),
1071
+ 'savedAt: ' + (typeof storage.savedAt === 'number' && storage.savedAt > 0 ? new Date(storage.savedAt).toISOString() : ''),
1072
+ 'migratedFrom: ' + v(storage.migratedFrom),
1073
+ 'backupDir: ' + v(storage.backupDir),
1074
+ 'isDefaultBackupDir: ' + v(storage.isDefaultBackupDir),
1075
+ 'backupPath: ' + v(storage.backupPath),
1076
+ 'historyDir: ' + v(storage.historyDir),
1077
+ 'historyCount: ' + v(storage.historyCount),
1078
+ 'schemaFile: ' + v(storage.schemaFile),
1079
+ '--- injection ---',
1080
+ 'registered: ' + v(injection.registered),
1081
+ 'muted: ' + v(injection.muted),
1082
+ 'error: ' + v(injection.error),
1083
+ 'chars: ' + v(injection.chars),
1084
+ 'budget: ' + v(injection.budget),
1085
+ 'pulls(since boot): ' + v(injection.pulls),
1086
+ '--- security ---',
1087
+ 'hostGuarded: ' + v(security.hostGuarded),
1088
+ 'boundHost: ' + v(security.boundHost),
1089
+ '--- errors/warnings ---',
1090
+ 'storage.error: ' + v(storage.error),
1091
+ 'storage.warning: ' + v(storage.warning),
1092
+ 'historyError: ' + v(storage.historyError),
1093
+ '--- environment ---',
1094
+ 'userAgent: ' + v(environment.userAgent),
1095
+ 'language: ' + v(environment.language),
1096
+ 'time: ' + v(environment.now),
1097
+ ].join('\n')
1098
+ }
1099
+
1100
+ /* ---------------- F17: local relevance preview ---------------- */
1101
+
1102
+ /** The most terms one query may be split into; keeps matching cheap. */
1103
+ const SIM_MAX_TERMS = 40
1104
+ /** Overlap thresholds for the three displayed levels (see relevancePreview). */
1105
+ const SIM_LEVEL_HIGH = 18
1106
+ const SIM_LEVEL_MID = 7
1107
+ /**
1108
+ * Field weights. The scenario text ("适用场景") carries the most signal
1109
+ * because it is the field the user writes FOR matching; the body counts
1110
+ * least (a long instruction mentions many words incidentally).
1111
+ */
1112
+ const SIM_WEIGHTS = [['when', 3], ['title', 2], ['content', 1]]
1113
+ /** Gram-sized function words that carry no signal on their own. */
1114
+ const SIM_STOP_GRAMS = new Set([
1115
+ '的是', '了的', '什么', '怎么', '如何', '可以', '一个', '一下', '进行', '以及', '或者',
1116
+ '如果', '因为', '所以', '但是', '然后', '已经', '需要', '应该', '可能', '我的', '你的',
1117
+ '我这', '你那', '就是', '还是', '有没有', '这个', '那个', '时候', '一些', '有点',
1118
+ ])
1119
+
1120
+ /**
1121
+ * Split a free-text task description into the terms the local preview
1122
+ * matches on.
1123
+ *
1124
+ * CJK has no word boundaries, so a run of CJK is expanded into 3- and
1125
+ * 2-character grams (longer grams first, so the specific ones win the term
1126
+ * budget); Latin/digit runs become single terms. Deterministic and pure —
1127
+ * exported so the heuristic itself is pinnable by tests, and capped so a
1128
+ * pasted paragraph cannot turn into thousands of comparisons.
1129
+ */
1130
+ function relevanceTerms(query) {
1131
+ if (typeof query !== 'string') return []
1132
+ const text = query.toLowerCase().trim()
1133
+ if (text === '') return []
1134
+ const terms = []
1135
+ const seen = new Set()
1136
+ const push = (term) => {
1137
+ if (term.length < 2 || seen.has(term) || terms.length >= SIM_MAX_TERMS) return
1138
+ seen.add(term)
1139
+ terms.push(term)
1140
+ }
1141
+ for (const chunk of text.split(/[^\p{L}\p{N}]+/u)) {
1142
+ if (chunk === '' || terms.length >= SIM_MAX_TERMS) continue
1143
+ if (/[\u3400-\u9fff\uf900-\ufaff]/.test(chunk)) {
1144
+ for (let size = 3; size >= 2; size -= 1) {
1145
+ for (let at = 0; at + size <= chunk.length && terms.length < SIM_MAX_TERMS; at += 1) {
1146
+ const gram = chunk.slice(at, at + size)
1147
+ if (!SIM_STOP_GRAMS.has(gram)) push(gram)
1148
+ }
1149
+ }
1150
+ // A short CJK chunk as one term too: more specific than its grams.
1151
+ if (chunk.length <= 6) push(chunk)
1152
+ } else {
1153
+ push(chunk)
1154
+ }
1155
+ }
1156
+ return terms
1157
+ }
1158
+
1159
+ /**
1160
+ * Score every on-demand ("按需" / auto) entry against a task description.
1161
+ *
1162
+ * This is a LOCAL, LITERAL keyword heuristic — explicitly not a prediction
1163
+ * of what the model will do — so the result is used only to highlight
1164
+ * entries in the list and to help the user sharpen the 适用场景 field. The
1165
+ * score is `Σ term.length × field weight` over the terms that appear
1166
+ * literally, which makes a 3-character match in the scenario text outrank
1167
+ * a 2-character match in the body.
1168
+ *
1169
+ * @returns { terms, results, autoTotal } — `results` is ordered by score
1170
+ * descending and contains only entries with a non-zero overlap.
1171
+ */
1172
+ function relevancePreview(entries, query) {
1173
+ const terms = relevanceTerms(query)
1174
+ const results = []
1175
+ let autoTotal = 0
1176
+ if (terms.length === 0) return { terms, results, autoTotal }
1177
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
1178
+ if (entry === null || typeof entry !== 'object' || entry.mode !== 'auto') continue
1179
+ autoTotal += 1
1180
+ const fields = {
1181
+ when: typeof entry.when === 'string' ? entry.when.toLowerCase() : '',
1182
+ title: typeof entry.title === 'string' ? entry.title.toLowerCase() : '',
1183
+ content: typeof entry.content === 'string' ? entry.content.toLowerCase() : '',
1184
+ }
1185
+ let score = 0
1186
+ const matched = []
1187
+ for (const term of terms) {
1188
+ for (const [field, weight] of SIM_WEIGHTS) {
1189
+ if (!fields[field].includes(term)) continue
1190
+ score += term.length * weight
1191
+ if (matched.length < 6 && !matched.includes(term)) matched.push(term)
1192
+ }
1193
+ }
1194
+ if (score <= 0) continue
1195
+ results.push({
1196
+ id: typeof entry.id === 'string' ? entry.id : '',
1197
+ score,
1198
+ level: score >= SIM_LEVEL_HIGH ? 'high' : (score >= SIM_LEVEL_MID ? 'mid' : 'low'),
1199
+ terms: matched,
1200
+ whenEmpty: fields.when.trim() === '',
1201
+ })
1202
+ }
1203
+ results.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
1204
+ return { terms, results, autoTotal }
1205
+ }
1206
+
1207
+ /* ---------------- F16: source-mode validation ---------------- */
1208
+
1209
+ /**
1210
+ * Live validation for the source editor: syntax plus the cheap shape check
1211
+ * the Host will apply anyway (a JSON object, not an array/scalar). It
1212
+ * deliberately does NOT try to re-implement the Host's normalisation — the
1213
+ * authoritative disclosures (dropped items, corrected options, truncation,
1214
+ * version refusal) come back from the save itself, so the panel can never
1215
+ * promise something the Host does not do.
1216
+ *
1217
+ * Past `maxChars` the live parse is skipped: parsing multi-megabyte text on
1218
+ * every keystroke would freeze the panel, and the save path still enforces
1219
+ * the same ceiling with a readable refusal.
1220
+ */
1221
+ function sourceParseVerdict(text, maxChars) {
1222
+ if (typeof text !== 'string' || text.trim() === '') return { ok: false, message: t('source.empty') }
1223
+ if (typeof maxChars === 'number' && maxChars > 0 && text.length > maxChars) {
1224
+ return { ok: false, paused: true, message: t('source.truncatedInput', { size: Math.max(1, Math.round(text.length / 10000)) }) }
1225
+ }
1226
+ let parsed = null
1227
+ try {
1228
+ parsed = JSON.parse(text)
1229
+ } catch (err) {
1230
+ return { ok: false, message: t('source.invalid', { detail: String(err && err.message ? err.message : err) }) }
1231
+ }
1232
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1233
+ return { ok: false, message: t('source.notObject') }
1234
+ }
1235
+ return {
1236
+ ok: true,
1237
+ message: t('source.valid', {
1238
+ entries: Array.isArray(parsed.entries) ? parsed.entries.length : 0,
1239
+ profiles: Array.isArray(parsed.profiles) ? parsed.profiles.length : 0,
1240
+ sets: Array.isArray(parsed.sets) && parsed.sets.length > 0 ? parsed.sets.length : 1,
1241
+ }),
1242
+ }
1243
+ }
1244
+
736
1245
  async function callHost(method, args) {
737
1246
  // A hung host used to pin the panel on 保存中… forever; fail visibly
738
1247
  // after a generous timeout instead.
@@ -982,6 +1491,16 @@ window.__ModuleLoader__.load({
982
1491
  // draft: editing an instruction never closes an open persona and vice
983
1492
  // versa is handled by closing one when opening the other (see handlers).
984
1493
  const [profileDraft, setProfileDraft] = React.useState(null)
1494
+ // Latest live values of the two editor drafts. switchSet's success
1495
+ // callback is a closure created at click time; between the click and the
1496
+ // response landing, a draft can still be opened (the busy flag only
1497
+ // re-renders on the next pass), so the callback must read the drafts
1498
+ // through refs to see what is REALLY open when the switch lands — a
1499
+ // stale `draft === null` would leave the old set's form on screen.
1500
+ const draftRef = React.useRef(null)
1501
+ draftRef.current = draft
1502
+ const profileDraftRef = React.useRef(null)
1503
+ profileDraftRef.current = profileDraft
985
1504
  const [budgetText, setBudgetText] = React.useState('')
986
1505
  // Latest live value of the draft, because the SSE onmessage closure below
987
1506
  // is created once and must not read a stale first-render budgetText.
@@ -998,7 +1517,44 @@ window.__ModuleLoader__.load({
998
1517
  // F3: bulk-select mode and the chosen entry ids in it.
999
1518
  const [batchMode, setBatchMode] = React.useState(false)
1000
1519
  const [selected, setSelected] = React.useState([])
1520
+ // F13: the set form (null = closed). One small inline form serves create,
1521
+ // rename and duplicate, because all three are "type a name and confirm".
1522
+ const [setForm, setSetForm] = React.useState(null)
1523
+ // F16: source mode holds its own draft + the rev the draft was read at.
1524
+ // Capturing the rev at READ time is what makes an external write in
1525
+ // between a conflict instead of a silent overwrite of someone else's edit.
1526
+ const [sourceMode, setSourceMode] = React.useState(false)
1527
+ const [sourceText, setSourceText] = React.useState('')
1528
+ const [sourceBaseRev, setSourceBaseRev] = React.useState(undefined)
1529
+ const [sourceVerdict, setSourceVerdict] = React.useState(null)
1530
+ // The body ceiling for the live check, mirrored into a ref so the
1531
+ // debounced effect never has to depend on the snapshot object.
1532
+ const sourceCeilingRef = React.useRef(0)
1533
+ // F17: the local relevance preview.
1534
+ const [simOpen, setSimOpen] = React.useState(false)
1535
+ const [simQuery, setSimQuery] = React.useState('')
1536
+ // F14: the history list is fetched lazily, on first expand.
1537
+ const [historyOpen, setHistoryOpen] = React.useState(false)
1538
+ const [history, setHistory] = React.useState(null)
1539
+ const [historyBusy, setHistoryBusy] = React.useState(false)
1540
+ // F12: the diagnostics card is collapsed by default; a failed automatic
1541
+ // copy parks the dump here so it can be selected manually instead of
1542
+ // pretending the copy happened.
1543
+ const [diagOpen, setDiagOpen] = React.useState(false)
1544
+ const [diagFallback, setDiagFallback] = React.useState(null)
1545
+ // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1546
+ // ONLY while a pause is live (its effect cleans up after itself). The
1547
+ // tick drives nothing but the remaining-time text; authority stays with
1548
+ // the host snapshot (injection.muted / data.disabledUntil).
1549
+ const [pauseMenuOpen, setPauseMenuOpen] = React.useState(false)
1550
+ const [nowTick, setNowTick] = React.useState(Date.now())
1551
+ // F18: the backup-location draft (focused-input protection like budget).
1552
+ const [backupText, setBackupText] = React.useState('')
1553
+ const backupTextRef = React.useRef('')
1554
+ backupTextRef.current = backupText
1555
+ const backupFocusedRef = React.useRef(false)
1001
1556
  const importInputRef = React.useRef(null)
1557
+ const restoreInputRef = React.useRef(null)
1002
1558
  // Whether the budget input is being edited right now. A live SSE push
1003
1559
  // from another window must not replace the number under the cursor;
1004
1560
  // the ref (not state) is read inside absorb so no re-render is needed.
@@ -1034,6 +1590,40 @@ window.__ModuleLoader__.load({
1034
1590
  return () => clearTimeout(timer)
1035
1591
  }, [undo])
1036
1592
 
1593
+ // F7: tick once per second while a pause is live so the remaining-time
1594
+ // text counts down. The interval exists only while muted — the cleanup
1595
+ // clears it the moment the snapshot says otherwise. This effect sits
1596
+ // BEFORE the early returns (a hook skipped by a conditional return would
1597
+ // desync the hook order); it reads the snapshot through `view` because
1598
+ // the derived `injection` does not exist yet up here.
1599
+ const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1600
+ && view.injection.muted === true
1601
+ React.useEffect(() => {
1602
+ if (!mutedNow) return undefined
1603
+ const tick = setInterval(() => setNowTick(Date.now()), 1000)
1604
+ return () => clearInterval(tick)
1605
+ }, [mutedNow])
1606
+
1607
+ /**
1608
+ * F16 live validation, debounced: parsing on every keystroke would stall
1609
+ * the panel on a multi-megabyte paste, and 250 ms is below the threshold
1610
+ * where typing feels unsynced. The verdict is advisory — the authoritative
1611
+ * answer (including every correction the loader makes) comes from the
1612
+ * save itself.
1613
+ *
1614
+ * The ceiling comes through a ref, and the deps are only [sourceMode,
1615
+ * sourceText]: a new snapshot object arrives on every push (and the
1616
+ * ceiling never changes), so depending on the view would re-arm the timer
1617
+ * on every frame and the verdict would visibly lag the typing.
1618
+ */
1619
+ React.useEffect(() => {
1620
+ if (!sourceMode) return undefined
1621
+ const timer = setTimeout(() => {
1622
+ setSourceVerdict(sourceParseVerdict(sourceText, sourceCeilingRef.current))
1623
+ }, 250)
1624
+ return () => clearTimeout(timer)
1625
+ }, [sourceMode, sourceText])
1626
+
1037
1627
  /**
1038
1628
  * The rev of the snapshot this panel is currently rendering. Sent back as
1039
1629
  * baseRev on every mutation so the Host can refuse a write built on a
@@ -1042,7 +1632,12 @@ window.__ModuleLoader__.load({
1042
1632
  * Host whose snapshot carries no rev — JSON.stringify drops the key, so
1043
1633
  * the request stays byte-identical to what that Host expects.
1044
1634
  */
1045
- const revOf = () => (view !== null && view.data && typeof view.data.rev === 'number' ? view.data.rev : undefined)
1635
+ const revOfFrom = (snapshot) => (snapshot !== null && typeof snapshot === 'object'
1636
+ && snapshot.data !== null && typeof snapshot.data === 'object'
1637
+ && typeof snapshot.data.rev === 'number'
1638
+ ? snapshot.data.rev
1639
+ : undefined)
1640
+ const revOf = () => revOfFrom(view)
1046
1641
 
1047
1642
  function absorb(result) {
1048
1643
  const normalized = normalizeResult(result)
@@ -1059,6 +1654,11 @@ window.__ModuleLoader__.load({
1059
1654
  // ref is the live draft — absorb may run from the SSE closure created
1060
1655
  // on the first render.
1061
1656
  setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
1657
+ // The backup-location field follows the same rule as the budget input:
1658
+ // a push from another window never rewrites what is being typed.
1659
+ setBackupText(nextBudgetText(backupTextRef.current,
1660
+ typeof normalized.snapshot.data.backupDir === 'string' ? normalized.snapshot.data.backupDir : '',
1661
+ backupFocusedRef.current))
1062
1662
  if (!normalized.ok && normalized.message) {
1063
1663
  setError(normalized.message)
1064
1664
  setNotice('')
@@ -1163,6 +1763,239 @@ window.__ModuleLoader__.load({
1163
1763
  void send('delete-profile', { id: profile.id, baseRev: revOf() })
1164
1764
  }
1165
1765
 
1766
+ // ---- F13 instruction sets ----
1767
+
1768
+ /** Switch the active set. The one click that changes what is injected. */
1769
+ function switchSet(id) {
1770
+ if (busy || id === '') return
1771
+ void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
1772
+ // A switch invalidates any list selection: the ids belong to the set
1773
+ // that is no longer displayed.
1774
+ if (result !== null && result.ok === true) {
1775
+ setSelected([])
1776
+ setUndo(null)
1777
+ // Batch mode exists to act on listed ids; after a switch it would
1778
+ // act on ids from the wrong set, so it leaves with the selection.
1779
+ setBatchMode(false)
1780
+ // An editor left open across a switch is a form full of the OLD
1781
+ // set's content that would save into the NEW set. The server-side
1782
+ // cross-set guard means no data is lost, but the form itself is a
1783
+ // lie — so it closes with the switch, and the user is told. The
1784
+ // refs (not the closure state) say whether anything was open.
1785
+ setDraft(null)
1786
+ setProfileDraft(null)
1787
+ if (draftRef.current !== null || profileDraftRef.current !== null) {
1788
+ const base = typeof result.message === 'string' && result.message !== ''
1789
+ ? result.message + ';'
1790
+ : ''
1791
+ setNotice(base + t('sets.draftsClosed'))
1792
+ }
1793
+ }
1794
+ })
1795
+ }
1796
+
1797
+ function submitSetForm() {
1798
+ if (setForm === null || busy) return
1799
+ const name = setForm.name.trim()
1800
+ if (name === '') {
1801
+ setError(t('sets.nameEmpty'))
1802
+ return
1803
+ }
1804
+ const call = setForm.mode === 'create'
1805
+ ? send('save-set', { set: { id: '', name }, baseRev: revOf() })
1806
+ : (setForm.mode === 'rename'
1807
+ ? send('save-set', { set: { id: setForm.id, name }, baseRev: revOf() })
1808
+ : send('duplicate-set', { id: setForm.id, name, baseRev: revOf() }))
1809
+ void call.then((result) => {
1810
+ if (result !== null && result.ok === true) setSetForm(null)
1811
+ })
1812
+ }
1813
+
1814
+ function removeSet(set) {
1815
+ if (busy) return
1816
+ if (set.active === true) {
1817
+ setError(t('sets.deleteActive'))
1818
+ return
1819
+ }
1820
+ if (typeof window.confirm === 'function'
1821
+ && !window.confirm(t('sets.deleteConfirm', { name: set.name, entries: set.entries, profiles: set.profiles }))) return
1822
+ void send('delete-set', { id: set.id, baseRev: revOf() })
1823
+ }
1824
+
1825
+ // ---- F14 version history ----
1826
+
1827
+ async function loadHistory() {
1828
+ setHistoryBusy(true)
1829
+ try {
1830
+ const result = await callHost('history-list', null)
1831
+ const normalized = normalizeResult(result)
1832
+ if (normalized === null || result.history === undefined || result.history === null) {
1833
+ setError(t('err.unrecognized'))
1834
+ return
1835
+ }
1836
+ setHistory({ dir: String(result.history.dir || ''), items: Array.isArray(result.history.items) ? result.history.items : [] })
1837
+ } catch (err) {
1838
+ setError(String(err && err.message ? err.message : err))
1839
+ } finally {
1840
+ setHistoryBusy(false)
1841
+ }
1842
+ }
1843
+
1844
+ function toggleHistory() {
1845
+ const next = !historyOpen
1846
+ setHistoryOpen(next)
1847
+ // Fetch on first expand (and on every expand after a write, so the list
1848
+ // matches the store the panel is showing).
1849
+ if (next) void loadHistory()
1850
+ }
1851
+
1852
+ function rollbackTo(item) {
1853
+ if (busy || typeof window.confirm !== 'function') return
1854
+ const when = new Date(item.epoch).toLocaleString()
1855
+ if (!window.confirm(t('history.restoreConfirm', { time: when, rev: item.rev }))) return
1856
+ void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
1857
+ if (result !== null && result.ok === true) void loadHistory()
1858
+ })
1859
+ }
1860
+
1861
+ function removeSnapshot(item) {
1862
+ if (busy) return
1863
+ if (typeof window.confirm === 'function' && !window.confirm(t('history.deleteConfirm'))) return
1864
+ void send('history-delete', { name: item.name }).then((result) => {
1865
+ if (result !== null && result.ok === true) void loadHistory()
1866
+ })
1867
+ }
1868
+
1869
+ // ---- F16 source mode ----
1870
+
1871
+ async function openSource() {
1872
+ if (busy) return
1873
+ if (draft !== null || profileDraft !== null) {
1874
+ setError(t('source.editorOpen'))
1875
+ return
1876
+ }
1877
+ try {
1878
+ const result = await callHost('read-source', null)
1879
+ if (result === null || typeof result !== 'object' || typeof result.source !== 'string') {
1880
+ // A refusal carries a reason (e.g. the file declares a newer format
1881
+ // version, so source mode is paused) — show THAT, not a generic
1882
+ // "unrecognized data".
1883
+ setError(result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
1884
+ ? result.message
1885
+ : t('err.unrecognized'))
1886
+ return
1887
+ }
1888
+ setSourceText(result.source)
1889
+ setSourceBaseRev(revOfFrom(result.snapshot))
1890
+ setSourceVerdict(null)
1891
+ setSourceMode(true)
1892
+ setShowPreview(false)
1893
+ setSimOpen(false)
1894
+ setError('')
1895
+ setNotice('')
1896
+ } catch (err) {
1897
+ setError(String(err && err.message ? err.message : err))
1898
+ }
1899
+ }
1900
+
1901
+ function reloadSource() {
1902
+ if (typeof window.confirm === 'function' && !window.confirm(t('source.reloadConfirm'))) return
1903
+ void openSource()
1904
+ }
1905
+
1906
+ function saveSource() {
1907
+ if (busy) return
1908
+ void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
1909
+ if (result === null || result.ok !== true) return
1910
+ // The draft now matches the committed store, so the rev it is based
1911
+ // on must move with it — otherwise the very next save would be
1912
+ // refused as a conflict against our own write.
1913
+ setSourceBaseRev(revOfFrom(result.snapshot))
1914
+ void loadHistory()
1915
+ })
1916
+ }
1917
+
1918
+ // ---- F18 backup location ----
1919
+
1920
+ function applyBackupDir() {
1921
+ if (busy) return
1922
+ void send('set-options', { backupDir: backupText, baseRev: revOf() })
1923
+ }
1924
+
1925
+ function exportToDir() {
1926
+ if (busy) return
1927
+ void send('export-to-dir', null)
1928
+ }
1929
+
1930
+ // ---- F14 whole-store restore (the "replace" door import does not open) ----
1931
+
1932
+ async function handleRestoreFile(file) {
1933
+ try {
1934
+ if (typeof window.confirm === 'function' && !window.confirm(t('restore.confirm'))) return
1935
+ const text = typeof file.text === 'function'
1936
+ ? await file.text()
1937
+ : await new Promise((resolve, reject) => {
1938
+ const reader = new FileReader()
1939
+ reader.onload = () => resolve(String(reader.result))
1940
+ reader.onerror = () => reject(reader.error)
1941
+ reader.readAsText(file)
1942
+ })
1943
+ let payload
1944
+ try {
1945
+ payload = JSON.parse(text)
1946
+ } catch (err) {
1947
+ setError(t('import.notJson', { detail: String(err && err.message ? err.message : err) }))
1948
+ return
1949
+ }
1950
+ await send('restore-data', { payload, baseRev: revOf() })
1951
+ } catch (err) {
1952
+ setError(String(err && err.message ? err.message : err))
1953
+ }
1954
+ }
1955
+
1956
+ // ---- F12 diagnostics copy ----
1957
+
1958
+ function copyDiagnostics() {
1959
+ if (view === null) return
1960
+ const text = diagnosticsText(view, {
1961
+ userAgent: typeof navigator !== 'undefined' && navigator.userAgent !== undefined ? String(navigator.userAgent) : '',
1962
+ language: typeof navigator !== 'undefined' && navigator.language !== undefined ? String(navigator.language) : '',
1963
+ now: new Date().toISOString(),
1964
+ })
1965
+ const clipboard = typeof navigator !== 'undefined' && navigator.clipboard
1966
+ && typeof navigator.clipboard.writeText === 'function' ? navigator.clipboard : null
1967
+ if (clipboard !== null) {
1968
+ clipboard.writeText(text).then(() => setNotice(t('diag.copied')), () => fallbackCopyDiagnostics(text))
1969
+ return
1970
+ }
1971
+ fallbackCopyDiagnostics(text)
1972
+ }
1973
+
1974
+ /**
1975
+ * The webview may ship neither the async clipboard API nor
1976
+ * document.execCommand; then the only honest move is to surface the dump
1977
+ * in a selectable textarea instead of pretending the copy happened.
1978
+ */
1979
+ function fallbackCopyDiagnostics(text) {
1980
+ try {
1981
+ const area = document.createElement('textarea')
1982
+ area.value = text
1983
+ area.setAttribute('readonly', '')
1984
+ const host = document.body || document.head
1985
+ if (host && typeof host.appendChild === 'function') host.appendChild(area)
1986
+ if (typeof area.select === 'function') area.select()
1987
+ const ok = typeof document.execCommand === 'function' && document.execCommand('copy') === true
1988
+ if (host && typeof host.removeChild === 'function' && area.parentNode === host) host.removeChild(area)
1989
+ if (ok) {
1990
+ setNotice(t('diag.copied'))
1991
+ return
1992
+ }
1993
+ throw new Error('copy rejected')
1994
+ } catch {
1995
+ setDiagFallback(text)
1996
+ }
1997
+ }
1998
+
1166
1999
  async function doExport() {
1167
2000
  try {
1168
2001
  const result = await callHost('export-data', null)
@@ -1280,8 +2113,19 @@ window.__ModuleLoader__.load({
1280
2113
  // Older hosts can hand over snapshots without storage/limits; fall back
1281
2114
  // instead of throwing mid-render.
1282
2115
  const storage = view.storage || {}
1283
- const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
2116
+ const limits = view.limits || {
2117
+ minBudget: 800, maxBudget: 40000, maxEntries: 200, maxProfiles: 50,
2118
+ maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
2119
+ }
2120
+ // Mirrored for the debounced source check (see its effect above): reading
2121
+ // it here keeps the effect's dependency list down to the text itself.
2122
+ sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
1284
2123
  const injection = view.injection || { registered: false, chars: 0, error: null }
2124
+ // F12: the security block is optional (older Host) and then means "no
2125
+ // known problem" — an absent field must not manufacture a warning.
2126
+ const security = view.security !== null && typeof view.security === 'object'
2127
+ ? view.security
2128
+ : { hostGuarded: true, boundHost: '' }
1285
2129
  // List order is display-only and never changes what the Host injects
1286
2130
  // (the injection order is decided host-side). The default mirrors the
1287
2131
  // previous fixed order; updated-time and title are the other sorts.
@@ -1327,6 +2171,39 @@ window.__ModuleLoader__.load({
1327
2171
  // Older hosts simply publish none of these fields: the meter falls back
1328
2172
  // to data.budgetChars and the maps stay empty.
1329
2173
  const meter = budgetMeter(view)
2174
+ /**
2175
+ * F13: the set list. A newer Host publishes the derived block (with
2176
+ * per-set counts and the active flag); an older one only has
2177
+ * data.sets/activeSetId, so the fallback builds the same shape with
2178
+ * zeroed counts rather than hiding the switcher.
2179
+ */
2180
+ const setsBlock = view.sets !== null && typeof view.sets === 'object' ? view.sets : null
2181
+ // A Host from before F13 publishes neither sets nor activeSetId; the set
2182
+ // UI is then simply absent instead of showing a made-up single set.
2183
+ const setsCapable = typeof data.activeSetId === 'string' && data.activeSetId !== ''
2184
+ const fallbackSets = setsCapable
2185
+ ? (Array.isArray(data.sets) && data.sets.length > 0
2186
+ ? data.sets
2187
+ : [{ id: data.activeSetId, name: t('sets.section') }])
2188
+ : []
2189
+ const setsList = setsBlock !== null && Array.isArray(setsBlock.list)
2190
+ ? setsBlock.list
2191
+ : fallbackSets.map((set) => ({
2192
+ id: set.id,
2193
+ name: set.name,
2194
+ active: set.id === data.activeSetId,
2195
+ entries: set.id === data.activeSetId ? data.entries.length : 0,
2196
+ profiles: set.id === data.activeSetId ? data.profiles.length : 0,
2197
+ }))
2198
+ const setsActiveId = setsBlock !== null && typeof setsBlock.activeId === 'string' ? setsBlock.activeId : data.activeSetId
2199
+ const setTotalEntries = setsBlock !== null && setsBlock.total && typeof setsBlock.total.entries === 'number'
2200
+ ? setsBlock.total.entries
2201
+ : data.entries.length
2202
+ const activeSetName = (setsList.find((set) => set.id === setsActiveId) || {}).name || ''
2203
+ // F17: the local preview scores the ACTIVE set's on-demand entries.
2204
+ const autoEntries = data.entries.filter((entry) => entry.mode === 'auto')
2205
+ const sim = relevancePreview(autoEntries, simQuery)
2206
+ const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
1330
2207
  const omittedById = new Map()
1331
2208
  const truncatedById = new Map()
1332
2209
  {
@@ -1367,20 +2244,90 @@ window.__ModuleLoader__.load({
1367
2244
  : (injection.registered
1368
2245
  ? t('status.mounted', { chars: injection.chars })
1369
2246
  : (activeCount === 0 ? t('status.notMountedEmpty') : t('status.notMounted')))
2247
+ // F12: display-only rows. Every value comes straight from the snapshot;
2248
+ // a missing field (older Host) degrades to (无) instead of throwing
2249
+ // mid-render.
2250
+ const diagValue = (value) => (value === null || value === undefined || value === '' ? t('diag.none') : String(value))
2251
+ const diagYesNo = (value) => (value === true ? t('diag.yes') : t('diag.no'))
2252
+ const diagRow = (label, value) => h('div', { className: 'im-meta' }, label + ':' + value)
2253
+ const diagLoadedFrom = view.diagnostics !== null && typeof view.diagnostics === 'object'
2254
+ ? view.diagnostics.loadedFrom
2255
+ : null
2256
+ const diagRev = view.diagnostics !== null && typeof view.diagnostics === 'object'
2257
+ && typeof view.diagnostics.rev === 'number'
2258
+ ? view.diagnostics.rev
2259
+ : (data.rev || 0)
2260
+ // F7: the pause decision itself belongs to the host (injection.muted);
2261
+ // the countdown below is display-only. At zero the bar yields back to
2262
+ // the normal control (the host's own timer is delivering a fresh
2263
+ // snapshot at that moment in any case).
2264
+ const muted = injection.muted === true
2265
+ const disabledUntil = typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
2266
+ ? data.disabledUntil
2267
+ : 0
2268
+ const muteRemainingMs = muted ? Math.max(0, disabledUntil - nowTick) : 0
2269
+ const muteRemainingText = (ms) => {
2270
+ const totalSec = Math.max(0, Math.round(ms / 1000))
2271
+ const hh = Math.floor(totalSec / 3600)
2272
+ const mm = Math.floor((totalSec % 3600) / 60)
2273
+ const ss = totalSec % 60
2274
+ return hh > 0 ? t('mute.remainingH', { h: hh, m: mm }) : t('mute.remainingMs', { m: mm, s: ss })
2275
+ }
2276
+ const endOfToday = () => {
2277
+ const d = new Date()
2278
+ d.setHours(24, 0, 0, 0)
2279
+ return d.getTime()
2280
+ }
2281
+ function pauseFor(ms) {
2282
+ if (busy || typeof ms !== 'number' || !(ms > 0)) return
2283
+ setPauseMenuOpen(false)
2284
+ void send('set-options', { disabledUntil: Date.now() + ms, baseRev: revOf() })
2285
+ }
2286
+ function resumePause() {
2287
+ if (busy) return
2288
+ setPauseMenuOpen(false)
2289
+ void send('set-options', { disabledUntil: 0, baseRev: revOf() })
2290
+ }
1370
2291
 
1371
2292
  const header = h('div', { className: 'im-card', key: 'head' },
2293
+ // F12: the non-loopback warning sits at the TOP of the panel — not
2294
+ // inside the collapsible diagnostics card — so it cannot be missed.
2295
+ security.hostGuarded === false
2296
+ ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2297
+ t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2298
+ : null,
1372
2299
  h('div', { className: 'im-row' },
1373
2300
  h('div', null,
1374
2301
  h('h3', { className: 'im-h' }, t('panel.title')),
1375
2302
  h('p', { className: 'im-sub' }, t('panel.subtitle'))),
1376
- h('label', { className: 'im-switch' },
1377
- h('input', {
1378
- type: 'checkbox',
1379
- checked: data.enabled === true,
1380
- disabled: busy,
1381
- onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
1382
- }),
1383
- t('panel.masterSwitch'))),
2303
+ h('div', { className: 'im-master-col' },
2304
+ h('label', { className: 'im-switch' },
2305
+ h('input', {
2306
+ type: 'checkbox',
2307
+ checked: data.enabled === true,
2308
+ disabled: busy,
2309
+ onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
2310
+ }),
2311
+ t('panel.masterSwitch')),
2312
+ // F7 临时静音:暂停期间提示词与「未安装插件」逐字节一致;到期由宿主定时器
2313
+ // 自动恢复,无需用户操作、不写盘。倒计时只是展示,权威在快照。
2314
+ muted && muteRemainingMs > 0
2315
+ ? h('div', { className: 'im-pause-bar' },
2316
+ h('span', null, t('mute.active', { remaining: muteRemainingText(muteRemainingMs) })),
2317
+ h('button', { className: 'im-btn', disabled: busy, onClick: resumePause }, t('mute.resume')))
2318
+ : h('div', { className: 'im-pause-row' },
2319
+ h('button', {
2320
+ className: 'im-btn',
2321
+ disabled: busy,
2322
+ onClick: () => setPauseMenuOpen(!pauseMenuOpen),
2323
+ }, t('mute.menu')),
2324
+ pauseMenuOpen
2325
+ ? h('div', { className: 'im-pause-menu' },
2326
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(30 * 60000) }, t('mute.30m')),
2327
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(60 * 60000) }, t('mute.1h')),
2328
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => pauseFor(endOfToday() - Date.now()) }, t('mute.today')),
2329
+ h('div', { className: 'im-meta' }, t('mute.hint')))
2330
+ : null))),
1384
2331
  h('div', { className: 'im-status' },
1385
2332
  h('span', { className: dotClass }),
1386
2333
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
@@ -1429,6 +2376,70 @@ window.__ModuleLoader__.load({
1429
2376
  ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
1430
2377
  : null)
1431
2378
  : null,
2379
+ // ---- F13 配置集:列表 + 切换(唯一会改变注入内容的动作) ----
2380
+ setsList.length > 0
2381
+ ? h('div', { key: 'sets', className: 'im-sets' },
2382
+ h('div', { className: 'im-sets-row' },
2383
+ h('span', { className: 'im-section-title' }, t('sets.section')),
2384
+ h('span', { className: 'im-spacer' }),
2385
+ h('span', { className: 'im-meta' }, t('sets.total', { total: setTotalEntries, max: limits.maxEntries })),
2386
+ h('button', {
2387
+ className: 'im-btn',
2388
+ disabled: busy || setsList.length >= limits.maxSets,
2389
+ onClick: () => { setSetForm({ mode: 'create', id: '', name: '' }); setError('') },
2390
+ }, t('sets.create'))),
2391
+ h('div', { className: 'im-meta' }, t('sets.note')),
2392
+ setsList.map((set) => h('div', { className: 'im-sets-row', key: set.id },
2393
+ set.active === true
2394
+ ? h('span', { className: 'im-active-dot', title: t('sets.activeHint', { name: set.name }) })
2395
+ : h('span', { className: 'im-dot' }),
2396
+ h('span', { className: 'im-title' }, set.name),
2397
+ h('span', { className: 'im-meta' }, t('sets.itemMeta', { entries: set.entries, profiles: set.profiles })),
2398
+ set.active === true
2399
+ ? h('span', { className: 'im-tag im-tag-always' }, t('sets.activeBadge'))
2400
+ : null,
2401
+ h('span', { className: 'im-spacer' }),
2402
+ set.active === true
2403
+ ? null
2404
+ : h('button', {
2405
+ className: 'im-btn im-btn-primary',
2406
+ disabled: busy,
2407
+ title: t('sets.switchHint'),
2408
+ onClick: () => switchSet(set.id),
2409
+ }, t('sets.switch')),
2410
+ h('button', {
2411
+ className: 'im-btn',
2412
+ disabled: busy,
2413
+ onClick: () => { setSetForm({ mode: 'rename', id: set.id, name: set.name }); setError('') },
2414
+ }, t('sets.rename')),
2415
+ h('button', {
2416
+ className: 'im-btn',
2417
+ disabled: busy || setTotalEntries + set.entries > limits.maxEntries,
2418
+ onClick: () => { setSetForm({ mode: 'duplicate', id: set.id, name: set.name + t('sets.copySuffix') }); setError('') },
2419
+ }, t('sets.duplicate')),
2420
+ set.active === true
2421
+ ? null
2422
+ : h('button', {
2423
+ className: 'im-btn im-btn-danger',
2424
+ disabled: busy,
2425
+ onClick: () => removeSet(set),
2426
+ }, t('sets.delete')))),
2427
+ setForm !== null
2428
+ ? h('div', { className: 'im-inline-form' },
2429
+ h('span', { className: 'im-meta' }, t('sets.nameLabel')),
2430
+ h('input', {
2431
+ className: 'im-input',
2432
+ value: setForm.name,
2433
+ maxLength: limits.maxSetName,
2434
+ disabled: busy,
2435
+ placeholder: t('sets.namePlaceholder'),
2436
+ onChange: (ev) => setSetForm({ mode: setForm.mode, id: setForm.id, name: ev.target.value }),
2437
+ onKeyDown: (ev) => { if (ev.key === 'Enter') submitSetForm() },
2438
+ }),
2439
+ h('button', { className: 'im-btn im-btn-primary', disabled: busy, onClick: submitSetForm }, t('sets.confirm')),
2440
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setSetForm(null) }, t('editor.cancel')))
2441
+ : null)
2442
+ : null,
1432
2443
  // ---- User persona ("用户形象"): facts about the USER, not a role ----
1433
2444
  h('div', { key: 'profiles', className: 'im-profiles' },
1434
2445
  h('div', { className: 'im-profiles-head' },
@@ -1532,6 +2543,49 @@ window.__ModuleLoader__.load({
1532
2543
  }, t('batch.delete')),
1533
2544
  h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
1534
2545
  : null,
2546
+ // ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
2547
+ (data.enabled === true && autoEntries.length > 0)
2548
+ ? h('div', { key: 'sim', className: 'im-profiles' },
2549
+ h('div', { className: 'im-profiles-head' },
2550
+ h('span', { className: 'im-section-title' }, t('sim.title')),
2551
+ h('span', { className: 'im-spacer' }),
2552
+ h('button', {
2553
+ className: 'im-btn',
2554
+ disabled: busy,
2555
+ onClick: () => setSimOpen(!simOpen),
2556
+ }, simOpen ? t('sim.hide') : t('sim.toggle'))),
2557
+ simOpen
2558
+ ? h('div', null,
2559
+ // The disclaimer is deliberately NOT small print in grey: it is
2560
+ // a warning that this is a heuristic, so it reads as one.
2561
+ h('div', { className: 'im-warn im-profile-sub' }, t('sim.disclaimer')),
2562
+ h('input', {
2563
+ className: 'im-input',
2564
+ value: simQuery,
2565
+ disabled: busy,
2566
+ placeholder: t('sim.placeholder'),
2567
+ onChange: (ev) => setSimQuery(ev.target.value),
2568
+ }),
2569
+ h('div', { className: 'im-meta im-meter-line' }, t('sim.onlyAuto')),
2570
+ simQuery.trim() === ''
2571
+ ? h('div', { className: 'im-meta im-meter-line' }, t('sim.hint'))
2572
+ : h('div', { className: 'im-sim-result' },
2573
+ h('div', { className: 'im-meta' }, t('sim.summary', { total: sim.autoTotal, hit: sim.results.length })),
2574
+ sim.results.length === 0
2575
+ ? h('div', { className: 'im-meta' }, t('sim.none'))
2576
+ : sim.results.slice(0, 8).map((hit) => {
2577
+ const entry = data.entries.find((candidate) => candidate.id === hit.id)
2578
+ if (entry === undefined) return null
2579
+ return h('div', { className: 'im-sim-item', key: hit.id },
2580
+ h('span', { className: 'im-title' }, entry.title !== '' ? entry.title : t('entry.untitled')),
2581
+ h('span', { className: 'im-tag im-tag-sim-' + hit.level },
2582
+ hit.level === 'high' ? t('sim.levelHigh') : (hit.level === 'mid' ? t('sim.levelMid') : t('sim.levelLow'))),
2583
+ h('span', { className: 'im-meta' }, t('sim.matched', { terms: hit.terms.join(' / ') })),
2584
+ hit.whenEmpty ? h('span', { className: 'im-warn' }, t('sim.noWhen')) : null,
2585
+ omittedById.has(hit.id) ? h('span', { className: 'im-tag im-tag-off' }, t('sim.omitted')) : null)
2586
+ })))
2587
+ : null)
2588
+ : null,
1535
2589
  // F2 filters: tag chips (only when tags exist) plus mode/status/sort.
1536
2590
  entries.length > 0
1537
2591
  ? h('div', { key: 'filters', className: 'im-filter-row' },
@@ -1605,6 +2659,20 @@ window.__ModuleLoader__.load({
1605
2659
  if (el !== null && el !== undefined) el.click()
1606
2660
  },
1607
2661
  }, t('action.import')),
2662
+ h('button', {
2663
+ className: 'im-btn',
2664
+ disabled: busy,
2665
+ title: t('restore.hint'),
2666
+ onClick: () => {
2667
+ const el = restoreInputRef.current
2668
+ if (el !== null && el !== undefined) el.click()
2669
+ },
2670
+ }, t('action.restore')),
2671
+ h('button', {
2672
+ className: 'im-btn',
2673
+ disabled: busy || draft !== null || profileDraft !== null,
2674
+ onClick: () => { void openSource() },
2675
+ }, t('source.mode')),
1608
2676
  h('button', {
1609
2677
  className: 'im-btn',
1610
2678
  disabled: busy,
@@ -1626,6 +2694,116 @@ window.__ModuleLoader__.load({
1626
2694
  ? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
1627
2695
  : null,
1628
2696
  storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
2697
+ // ---- F18 备份位置 ----
2698
+ h('div', { key: 'backup', className: 'im-backup' },
2699
+ h('div', { className: 'im-section-title' }, t('backup.section')),
2700
+ h('label', { className: 'im-label' }, t('backup.label')),
2701
+ h('div', { className: 'im-backup-row' },
2702
+ h('input', {
2703
+ className: 'im-input',
2704
+ value: backupText,
2705
+ disabled: busy,
2706
+ maxLength: limits.maxBackupDir,
2707
+ placeholder: t('backup.placeholder'),
2708
+ onFocus: () => { backupFocusedRef.current = true },
2709
+ onChange: (ev) => setBackupText(ev.target.value),
2710
+ onBlur: () => { backupFocusedRef.current = false },
2711
+ }),
2712
+ h('button', { className: 'im-btn im-btn-primary', disabled: busy, onClick: applyBackupDir }, t('backup.apply')),
2713
+ h('button', { className: 'im-btn', disabled: busy, onClick: exportToDir }, t('backup.exportToDir'))),
2714
+ h('div', { className: 'im-meta' }, t('backup.hint')),
2715
+ h('div', { className: 'im-meta' }, t('backup.multiDevice'))),
2716
+ // ---- F14 版本历史 ----
2717
+ h('div', { key: 'history', className: 'im-history' },
2718
+ h('div', { className: 'im-profiles-head' },
2719
+ h('span', { className: 'im-section-title' }, t('history.section')),
2720
+ h('span', { className: 'im-spacer' }),
2721
+ history !== null ? h('span', { className: 'im-meta' }, t('history.count', { count: history.items.length })) : null,
2722
+ h('button', { className: 'im-btn', disabled: busy, onClick: toggleHistory }, historyOpen ? t('history.hide') : t('history.show'))),
2723
+ h('div', { className: 'im-meta im-profile-sub' }, t('history.subtitle', { max: limits.maxHistory })),
2724
+ historyOpen
2725
+ ? (historyBusy && history === null
2726
+ ? h('div', { className: 'im-meta' }, t('history.loading'))
2727
+ : (history === null || history.items.length === 0
2728
+ ? h('div', { className: 'im-meta' }, t('history.empty'))
2729
+ : h('div', { className: 'im-list-scroll' }, history.items.map((item) => {
2730
+ const when = new Date(item.epoch).toLocaleString()
2731
+ const size = Math.max(1, Math.round(item.bytes / 1024))
2732
+ return h('div', {
2733
+ className: 'im-history-item' + (item.rev === data.rev ? ' im-history-item-current' : ''),
2734
+ key: item.name,
2735
+ },
2736
+ h('span', { className: 'im-title' }, when),
2737
+ h('span', { className: 'im-tag' }, 'rev ' + item.rev),
2738
+ h('span', { className: 'im-history-sub' },
2739
+ item.entries === null
2740
+ ? t('history.itemMetaLite', { size })
2741
+ : t('history.itemMeta', {
2742
+ entries: item.entries,
2743
+ profiles: item.profiles === null ? 0 : item.profiles,
2744
+ size,
2745
+ })),
2746
+ item.foreign === true ? h('span', { className: 'im-tag im-tag-off' }, t('history.foreign', { device: item.device })) : null,
2747
+ h('span', { className: 'im-spacer' }),
2748
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => rollbackTo(item) }, t('history.restore')),
2749
+ h('button', { className: 'im-btn im-btn-danger', disabled: busy, onClick: () => removeSnapshot(item) }, t('history.delete')))
2750
+ }))))
2751
+ : null,
2752
+ history !== null && history.dir !== ''
2753
+ ? h('div', { className: 'im-meta' }, t('history.dir', { dir: history.dir }))
2754
+ : null),
2755
+ // ---- F12 诊断与安全状态(默认收起;纯展示,无写路由) ----
2756
+ h('div', { key: 'diag', className: 'im-diag' },
2757
+ h('div', { className: 'im-profiles-head' },
2758
+ h('span', { className: 'im-section-title' }, t('diag.section')),
2759
+ h('span', { className: 'im-spacer' }),
2760
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setDiagOpen(!diagOpen) },
2761
+ diagOpen ? t('diag.hide') : t('diag.show')),
2762
+ h('button', { className: 'im-btn', disabled: busy, onClick: copyDiagnostics }, t('diag.copy'))),
2763
+ diagOpen
2764
+ ? h('div', null,
2765
+ h('div', { className: 'im-diag-group' },
2766
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupStorage')),
2767
+ diagRow(t('diag.storagePath'), diagValue(storage.path)),
2768
+ diagRow(t('diag.onDisk'), diagYesNo(storage.onDisk === true)),
2769
+ diagRow(t('diag.loadedFrom'), diagValue(diagLoadedFrom)),
2770
+ diagRow(t('diag.savedAt'), typeof storage.savedAt === 'number' && storage.savedAt > 0
2771
+ ? new Date(storage.savedAt).toLocaleString()
2772
+ : t('diag.none')),
2773
+ diagRow(t('diag.rev'), String(diagRev))),
2774
+ h('div', { className: 'im-diag-group' },
2775
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupInjection')),
2776
+ diagRow(t('diag.registered'), diagYesNo(injection.registered === true)),
2777
+ diagRow(t('diag.pulls'), String(typeof injection.pulls === 'number' ? injection.pulls : 0)),
2778
+ diagRow(t('diag.chars'),
2779
+ String(typeof injection.chars === 'number' ? injection.chars : 0) + ' / '
2780
+ + String(typeof injection.budget === 'number' ? injection.budget : data.budgetChars))),
2781
+ h('div', { className: 'im-diag-group' },
2782
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupBackup')),
2783
+ diagRow(t('diag.backupDir'), typeof storage.backupDir === 'string' && storage.backupDir !== ''
2784
+ ? storage.backupDir
2785
+ : t('diag.backupDefault')),
2786
+ diagRow(t('diag.backupPath'), diagValue(storage.backupPath)),
2787
+ diagRow(t('diag.historyDir'), diagValue(storage.historyDir)),
2788
+ diagRow(t('diag.historyCount'), String(typeof storage.historyCount === 'number' ? storage.historyCount : 0)),
2789
+ diagRow(t('diag.schemaFile'), diagValue(storage.schemaFile))),
2790
+ h('div', { className: 'im-diag-group' },
2791
+ h('span', { className: 'im-section-title im-diag-sub' }, t('diag.groupIssues')),
2792
+ diagRow(t('diag.issueStorage'), diagValue(storage.error)),
2793
+ diagRow(t('diag.issueWarning'), diagValue(storage.warning)),
2794
+ diagRow(t('diag.issueHistory'), diagValue(storage.historyError)),
2795
+ diagRow(t('diag.issueSection'), diagValue(injection.error))),
2796
+ diagFallback !== null
2797
+ ? h('div', { className: 'im-diag-fallback' },
2798
+ h('div', { className: 'im-meta' }, t('diag.copyManual')),
2799
+ h('textarea', {
2800
+ className: 'im-textarea',
2801
+ readOnly: true,
2802
+ value: diagFallback,
2803
+ onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
2804
+ }))
2805
+ : null)
2806
+ : null),
1629
2807
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
1630
2808
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
1631
2809
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
@@ -1660,8 +2838,73 @@ window.__ModuleLoader__.load({
1660
2838
  ev.target.value = ''
1661
2839
  if (file !== null) void handleImportFile(file)
1662
2840
  },
2841
+ }),
2842
+ // F14: the whole-store restore picker. A separate input because its
2843
+ // confirmation and its semantics (replace, not merge) differ from import.
2844
+ h('input', {
2845
+ key: 'restore-input',
2846
+ type: 'file',
2847
+ accept: '.json,application/json',
2848
+ style: { display: 'none' },
2849
+ ref: restoreInputRef,
2850
+ onChange: (ev) => {
2851
+ const file = ev.target.files && ev.target.files.length > 0 ? ev.target.files[0] : null
2852
+ ev.target.value = ''
2853
+ if (file !== null) void handleRestoreFile(file)
2854
+ },
1663
2855
  }))
1664
2856
 
2857
+ /**
2858
+ * F16 源码模式 replaces the list area entirely: editing the raw JSON and
2859
+ * editing the cards are two views of ONE store, so they are never shown
2860
+ * at once (and the entry/persona editors must be closed first — see
2861
+ * openSource). The header card stays visible so the master switch, the
2862
+ * budget and the set switcher remain reachable.
2863
+ */
2864
+ if (sourceMode) {
2865
+ const canSave = sourceVerdict === null || sourceVerdict.ok === true || sourceVerdict.paused === true
2866
+ return h('div', { className: 'im-root' },
2867
+ header,
2868
+ h('div', { className: 'im-card' },
2869
+ h('div', { className: 'im-profiles-head' },
2870
+ h('span', { className: 'im-section-title' }, t('source.section')),
2871
+ h('span', { className: 'im-spacer' }),
2872
+ sourceBaseRev !== undefined
2873
+ ? h('span', { className: 'im-meta' }, 'rev ' + sourceBaseRev)
2874
+ : null),
2875
+ h('div', { className: 'im-meta' }, t('source.subtitle')),
2876
+ h('div', { className: 'im-source' },
2877
+ h('textarea', {
2878
+ className: 'im-textarea im-textarea-source',
2879
+ value: sourceText,
2880
+ disabled: busy,
2881
+ spellCheck: false,
2882
+ onChange: (ev) => setSourceText(ev.target.value),
2883
+ }),
2884
+ sourceVerdict !== null
2885
+ ? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
2886
+ : null,
2887
+ error !== '' ? h('div', { className: 'im-err' }, error) : null,
2888
+ // Saving can be refused (a conflict, a future version, a shape
2889
+ // problem). The draft is never thrown away by a refusal, and the
2890
+ // user needs to know that before deciding what to do next.
2891
+ error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
2892
+ error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
2893
+ h('div', { className: 'im-row-wrap' },
2894
+ h('button', {
2895
+ className: 'im-btn im-btn-primary',
2896
+ disabled: busy || !canSave,
2897
+ onClick: saveSource,
2898
+ }, busy ? t('editor.saving') : t('source.save')),
2899
+ h('button', { className: 'im-btn', disabled: busy, onClick: reloadSource }, t('source.reload')),
2900
+ h('span', { className: 'im-spacer' }),
2901
+ h('button', {
2902
+ className: 'im-btn',
2903
+ disabled: busy,
2904
+ onClick: () => { setSourceMode(false); setSourceVerdict(null); setError(''); setNotice('') },
2905
+ }, t('source.exit'))))))
2906
+ }
2907
+
1665
2908
  const items = visible.map((entry) => {
1666
2909
  const tags = [
1667
2910
  h('span', {
@@ -1682,6 +2925,16 @@ window.__ModuleLoader__.load({
1682
2925
  className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
1683
2926
  }, t('tag.truncated')))
1684
2927
  }
2928
+ // F17: the local preview's literal overlap, shown only while the
2929
+ // preview is open so the mark always comes with its disclaimer.
2930
+ const simHit = simOpen ? simById.get(entry.id) : undefined
2931
+ if (simHit !== undefined) {
2932
+ tags.push(h('span', {
2933
+ className: 'im-tag im-tag-sim-' + simHit.level,
2934
+ key: 'sim',
2935
+ title: t('sim.matched', { terms: simHit.terms.join(' / ') }),
2936
+ }, simHit.level === 'high' ? t('sim.levelHigh') : (simHit.level === 'mid' ? t('sim.levelMid') : t('sim.levelLow'))))
2937
+ }
1685
2938
  return h('div', {
1686
2939
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
1687
2940
  key: entry.id,
@@ -1855,6 +3108,10 @@ window.__ModuleLoader__.load({
1855
3108
  exports.__nextBudgetText = nextBudgetText
1856
3109
  exports.__budgetMeter = budgetMeter
1857
3110
  exports.__parseTagInput = parseTagInput
3111
+ exports.__relevanceTerms = relevanceTerms
3112
+ exports.__relevancePreview = relevancePreview
3113
+ exports.__sourceParseVerdict = sourceParseVerdict
3114
+ exports.__diagnosticsText = diagnosticsText
1858
3115
  exports.__strings = STRINGS
1859
3116
  exports.__ns = NS
1860
3117
  exports.__dictionaryT = dictionaryT