dsh-instruction-memory 1.1.0 → 1.2.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,41 @@ 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; }
121
156
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
122
157
  `
123
158
 
@@ -260,6 +295,84 @@ window.__ModuleLoader__.load({
260
295
  'undo.restore': '撤销删除',
261
296
  'undo.dismiss': '知道了',
262
297
  'undo.restored': '已恢复刚删除的指令',
298
+ // ---- F13 多情境配置集 ----
299
+ 'sets.section': '配置集',
300
+ 'sets.note': '把记忆分成互不相干的几套(如「工作」「个人」「某客户」)。注入只使用当前激活的一套;切换只改变注入内容,不会移动或删除任何文本。',
301
+ 'sets.total': '全部配置集合计 {total} / {max} 条指令',
302
+ 'sets.activeHint': '当前注入「{name}」',
303
+ 'sets.create': '+ 新建',
304
+ 'sets.rename': '重命名',
305
+ 'sets.duplicate': '复制',
306
+ 'sets.delete': '删除',
307
+ 'sets.nameLabel': '配置集名称',
308
+ 'sets.namePlaceholder': '例如:工作 / 个人 / 某客户',
309
+ 'sets.confirm': '确定',
310
+ 'sets.nameEmpty': '请先填写配置集名称',
311
+ 'sets.itemMeta': '{entries} 条指令 · {profiles} 个形象',
312
+ 'sets.activeBadge': '使用中',
313
+ 'sets.switch': '切换',
314
+ 'sets.switchHint': '切换到该配置集:注入内容立即变更',
315
+ 'sets.copySuffix': ' 副本',
316
+ 'sets.deleteConfirm': '确定删除配置集「{name}」?其中的 {entries} 条指令与 {profiles} 个用户形象会一并删除,且无法撤销。',
317
+ 'sets.deleteActive': '当前激活的配置集不能删除;请先切换到其他配置集。',
318
+ // ---- F14 版本历史 ----
319
+ 'history.section': '版本历史',
320
+ 'history.subtitle': '每次保存都会留一份带时间戳的快照(每台设备最多保留 {max} 份,逐出最旧的)。可在此查看并一键回滚。',
321
+ 'history.show': '查看版本历史',
322
+ 'history.hide': '收起',
323
+ 'history.count': '共 {count} 份',
324
+ 'history.loading': '正在读取…',
325
+ 'history.empty': '还没有历史快照(第一次保存后出现)。',
326
+ 'history.itemMeta': '{entries} 条指令 / {profiles} 个形象 · {size} KB',
327
+ 'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
328
+ 'history.foreign': '来自其他设备({device})',
329
+ 'history.restore': '回滚到此版本',
330
+ 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
331
+ 'history.delete': '删除',
332
+ 'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
333
+ 'history.dir': '快照目录:{dir}',
334
+ // ---- F16 源码模式 ----
335
+ 'source.mode': '源码模式',
336
+ 'source.exit': '退出源码模式',
337
+ 'source.section': '源码模式(整份 JSON)',
338
+ 'source.subtitle': '直接编辑整份存储文件(与 memory.json 内容一致)。保存时走与手改文件相同的校验与披露,并带 rev 冲突保护;保存后立即生效。',
339
+ 'source.save': '保存源码',
340
+ 'source.reload': '重新载入最新内容',
341
+ 'source.valid': 'JSON 语法正确:{entries} 条指令 / {profiles} 个形象 / {sets} 个配置集',
342
+ 'source.invalid': 'JSON 语法错误:{detail}',
343
+ 'source.empty': '内容为空',
344
+ 'source.notObject': '顶层必须是一个 JSON 对象',
345
+ 'source.keepDraft': '保存被拒绝,源码草稿未丢失;可修改后重试,或点「重新载入最新内容」放弃本地修改。',
346
+ 'source.reloadConfirm': '重新载入会丢弃当前编辑的源码草稿,确定吗?',
347
+ 'source.editorOpen': '请先关闭正在编辑的条目或形象,再进入源码模式',
348
+ 'source.truncatedInput': '源码过长({size} 万字符),已停止实时校验;保存时仍会按上限校验。',
349
+ // ---- F17 本地预演 ----
350
+ 'sim.title': '按需指令本地预演',
351
+ 'sim.toggle': '本地预演',
352
+ 'sim.hide': '收起预演',
353
+ 'sim.disclaimer': '本地预演 ≠ 模型的真实判断:这里只用字面关键词匹配各条「按需」指令的适用场景/标题/内容,帮你把「适用场景」写清楚、预判取舍。它不参与注入,也不代表模型实际会不会采纳。',
354
+ 'sim.placeholder': '输入一句当前任务描述,例如:帮我改一下这个配置文件',
355
+ 'sim.hint': '输入任务描述后,这里会列出字面相关度较高的「按需」指令;结果仅供自查。',
356
+ 'sim.levelHigh': '预演·高',
357
+ 'sim.levelMid': '预演·中',
358
+ 'sim.levelLow': '预演·低',
359
+ 'sim.matched': '字面命中:{terms}',
360
+ 'sim.noWhen': '未填写「适用场景」,建议补充',
361
+ 'sim.none': '没有「按需」指令与这段描述存在字面匹配。',
362
+ 'sim.onlyAuto': '仅「按需」指令参与预演;「始终」指令无条件生效,不参与匹配,也不受此结果影响。',
363
+ 'sim.summary': '共 {total} 条按需指令,字面命中 {hit} 条',
364
+ 'sim.omitted': '本轮因超出字数上限未注入',
365
+ // ---- F18 备份位置 ----
366
+ 'backup.section': '备份位置',
367
+ 'backup.label': '自定义备份位置(绝对路径;留空 = 与存储文件同目录)',
368
+ 'backup.placeholder': '例如 D:\\云盘\\dsh-memory 或 /home/me/cloud/dsh-memory',
369
+ 'backup.apply': '应用',
370
+ 'backup.exportToDir': '导出到备份目录',
371
+ 'backup.hint': '备份位置决定「版本历史」目录与「导出到备份目录」的去向,并额外保留一份 memory.json.bak;存储文件本身(及其跨进程锁)不会移动。它是本机设置,不会随导出文件带到别的机器。',
372
+ 'backup.multiDevice': '多设备建议:把备份位置指向本地云盘文件夹,只同步「导出文件」与「history 快照」;不要用云盘同步存储目录本身,两台机器同时写入会互相覆盖。',
373
+ 'action.restore': '从备份恢复…',
374
+ 'restore.hint': '用导出的备份文件整体替换当前记忆(导入只做合并,无法还原整份)',
375
+ 'restore.confirm': '确定用所选备份文件整体替换当前记忆?现有内容仍保留在 memory.json.bak 与版本历史中;配置集、条目与用户形象都会被文件内容替换(备份位置等本机设置保持不变)。',
263
376
  },
264
377
  en: {
265
378
  'section.label': 'Instruction Memory',
@@ -389,6 +502,84 @@ window.__ModuleLoader__.load({
389
502
  'undo.restore': 'Undo delete',
390
503
  'undo.dismiss': 'Dismiss',
391
504
  'undo.restored': 'The deleted instruction was restored',
505
+ // ---- F13 instruction sets ----
506
+ 'sets.section': 'Instruction sets',
507
+ '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.',
508
+ 'sets.total': '{total} of {max} instructions across all sets',
509
+ 'sets.activeHint': 'Injecting “{name}”',
510
+ 'sets.create': '+ New',
511
+ 'sets.rename': 'Rename',
512
+ 'sets.duplicate': 'Duplicate',
513
+ 'sets.delete': 'Delete',
514
+ 'sets.nameLabel': 'Set name',
515
+ 'sets.namePlaceholder': 'e.g. Work / Personal / Acme client',
516
+ 'sets.confirm': 'OK',
517
+ 'sets.nameEmpty': 'Enter a set name first',
518
+ 'sets.itemMeta': '{entries} instructions · {profiles} personas',
519
+ 'sets.activeBadge': 'In use',
520
+ 'sets.switch': 'Switch',
521
+ 'sets.switchHint': 'Switch to this set: the injected content changes immediately',
522
+ 'sets.copySuffix': ' copy',
523
+ 'sets.deleteConfirm': 'Delete the set “{name}”? Its {entries} instructions and {profiles} personas are deleted with it. This cannot be undone.',
524
+ 'sets.deleteActive': 'The active set cannot be deleted; switch to another set first.',
525
+ // ---- F14 version history ----
526
+ 'history.section': 'Version history',
527
+ 'history.subtitle': 'Every save leaves a timestamped snapshot ({max} kept per device, oldest evicted first). Review them here and roll back with one click.',
528
+ 'history.show': 'Show version history',
529
+ 'history.hide': 'Hide',
530
+ 'history.count': '{count} snapshots',
531
+ 'history.loading': 'Loading…',
532
+ 'history.empty': 'No snapshots yet (the first save creates one).',
533
+ 'history.itemMeta': '{entries} instructions / {profiles} personas · {size} KB',
534
+ 'history.itemMetaLite': '{size} KB (file too large to count items)',
535
+ 'history.foreign': 'From another device ({device})',
536
+ 'history.restore': 'Roll back to this',
537
+ '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.',
538
+ 'history.delete': 'Delete',
539
+ 'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
540
+ 'history.dir': 'Snapshot folder: {dir}',
541
+ // ---- F16 source mode ----
542
+ 'source.mode': 'Source mode',
543
+ 'source.exit': 'Exit source mode',
544
+ 'source.section': 'Source mode (whole JSON)',
545
+ '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.',
546
+ 'source.save': 'Save source',
547
+ 'source.reload': 'Reload latest',
548
+ 'source.valid': 'Valid JSON: {entries} instructions / {profiles} personas / {sets} sets',
549
+ 'source.invalid': 'Invalid JSON: {detail}',
550
+ 'source.empty': 'The content is empty',
551
+ 'source.notObject': 'The top level must be a JSON object',
552
+ 'source.keepDraft': 'The save was refused and your draft is intact; fix it and retry, or click “Reload latest” to discard local edits.',
553
+ 'source.reloadConfirm': 'Reloading discards the source draft you are editing. Continue?',
554
+ 'source.editorOpen': 'Close the open entry/persona editor before entering source mode',
555
+ 'source.truncatedInput': 'The source is very long ({size}0k characters); live validation is paused. Saving still enforces the limit.',
556
+ // ---- F17 local preview ----
557
+ 'sim.title': 'Local preview of on-demand rules',
558
+ 'sim.toggle': 'Local preview',
559
+ 'sim.hide': 'Hide preview',
560
+ '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.',
561
+ 'sim.placeholder': 'Describe the current task, e.g. help me edit this config file',
562
+ 'sim.hint': 'Type a task description to list the on-demand rules with the strongest literal overlap; the result is for your own inspection.',
563
+ 'sim.levelHigh': 'Preview · high',
564
+ 'sim.levelMid': 'Preview · medium',
565
+ 'sim.levelLow': 'Preview · low',
566
+ 'sim.matched': 'Literal matches: {terms}',
567
+ 'sim.noWhen': 'No scenario text — consider adding one',
568
+ 'sim.none': 'No on-demand rule overlaps this description literally.',
569
+ 'sim.onlyAuto': 'Only on-demand rules take part in the preview; always-on rules apply unconditionally and are unaffected by this result.',
570
+ 'sim.summary': '{total} on-demand rules, {hit} with a literal match',
571
+ 'sim.omitted': 'Not injected this round (over the size limit)',
572
+ // ---- F18 backup location ----
573
+ 'backup.section': 'Backup location',
574
+ 'backup.label': 'Custom backup location (absolute path; empty = next to the store file)',
575
+ 'backup.placeholder': 'e.g. D:\\cloud\\dsh-memory or /home/me/cloud/dsh-memory',
576
+ 'backup.apply': 'Apply',
577
+ 'backup.exportToDir': 'Export to backup folder',
578
+ '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.',
579
+ '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.',
580
+ 'action.restore': 'Restore from backup…',
581
+ 'restore.hint': 'Replace the whole memory with an exported backup file (import only merges, so it cannot restore a whole store)',
582
+ '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
583
  },
393
584
  }
394
585
 
@@ -733,6 +924,151 @@ window.__ModuleLoader__.load({
733
924
  return { budget, used, omitted, truncated, percent, tokens }
734
925
  }
735
926
 
927
+ /* ---------------- F17: local relevance preview ---------------- */
928
+
929
+ /** The most terms one query may be split into; keeps matching cheap. */
930
+ const SIM_MAX_TERMS = 40
931
+ /** Overlap thresholds for the three displayed levels (see relevancePreview). */
932
+ const SIM_LEVEL_HIGH = 18
933
+ const SIM_LEVEL_MID = 7
934
+ /**
935
+ * Field weights. The scenario text ("适用场景") carries the most signal
936
+ * because it is the field the user writes FOR matching; the body counts
937
+ * least (a long instruction mentions many words incidentally).
938
+ */
939
+ const SIM_WEIGHTS = [['when', 3], ['title', 2], ['content', 1]]
940
+ /** Gram-sized function words that carry no signal on their own. */
941
+ const SIM_STOP_GRAMS = new Set([
942
+ '的是', '了的', '什么', '怎么', '如何', '可以', '一个', '一下', '进行', '以及', '或者',
943
+ '如果', '因为', '所以', '但是', '然后', '已经', '需要', '应该', '可能', '我的', '你的',
944
+ '我这', '你那', '就是', '还是', '有没有', '这个', '那个', '时候', '一些', '有点',
945
+ ])
946
+
947
+ /**
948
+ * Split a free-text task description into the terms the local preview
949
+ * matches on.
950
+ *
951
+ * CJK has no word boundaries, so a run of CJK is expanded into 3- and
952
+ * 2-character grams (longer grams first, so the specific ones win the term
953
+ * budget); Latin/digit runs become single terms. Deterministic and pure —
954
+ * exported so the heuristic itself is pinnable by tests, and capped so a
955
+ * pasted paragraph cannot turn into thousands of comparisons.
956
+ */
957
+ function relevanceTerms(query) {
958
+ if (typeof query !== 'string') return []
959
+ const text = query.toLowerCase().trim()
960
+ if (text === '') return []
961
+ const terms = []
962
+ const seen = new Set()
963
+ const push = (term) => {
964
+ if (term.length < 2 || seen.has(term) || terms.length >= SIM_MAX_TERMS) return
965
+ seen.add(term)
966
+ terms.push(term)
967
+ }
968
+ for (const chunk of text.split(/[^\p{L}\p{N}]+/u)) {
969
+ if (chunk === '' || terms.length >= SIM_MAX_TERMS) continue
970
+ if (/[\u3400-\u9fff\uf900-\ufaff]/.test(chunk)) {
971
+ for (let size = 3; size >= 2; size -= 1) {
972
+ for (let at = 0; at + size <= chunk.length && terms.length < SIM_MAX_TERMS; at += 1) {
973
+ const gram = chunk.slice(at, at + size)
974
+ if (!SIM_STOP_GRAMS.has(gram)) push(gram)
975
+ }
976
+ }
977
+ // A short CJK chunk as one term too: more specific than its grams.
978
+ if (chunk.length <= 6) push(chunk)
979
+ } else {
980
+ push(chunk)
981
+ }
982
+ }
983
+ return terms
984
+ }
985
+
986
+ /**
987
+ * Score every on-demand ("按需" / auto) entry against a task description.
988
+ *
989
+ * This is a LOCAL, LITERAL keyword heuristic — explicitly not a prediction
990
+ * of what the model will do — so the result is used only to highlight
991
+ * entries in the list and to help the user sharpen the 适用场景 field. The
992
+ * score is `Σ term.length × field weight` over the terms that appear
993
+ * literally, which makes a 3-character match in the scenario text outrank
994
+ * a 2-character match in the body.
995
+ *
996
+ * @returns { terms, results, autoTotal } — `results` is ordered by score
997
+ * descending and contains only entries with a non-zero overlap.
998
+ */
999
+ function relevancePreview(entries, query) {
1000
+ const terms = relevanceTerms(query)
1001
+ const results = []
1002
+ let autoTotal = 0
1003
+ if (terms.length === 0) return { terms, results, autoTotal }
1004
+ for (const entry of (Array.isArray(entries) ? entries : [])) {
1005
+ if (entry === null || typeof entry !== 'object' || entry.mode !== 'auto') continue
1006
+ autoTotal += 1
1007
+ const fields = {
1008
+ when: typeof entry.when === 'string' ? entry.when.toLowerCase() : '',
1009
+ title: typeof entry.title === 'string' ? entry.title.toLowerCase() : '',
1010
+ content: typeof entry.content === 'string' ? entry.content.toLowerCase() : '',
1011
+ }
1012
+ let score = 0
1013
+ const matched = []
1014
+ for (const term of terms) {
1015
+ for (const [field, weight] of SIM_WEIGHTS) {
1016
+ if (!fields[field].includes(term)) continue
1017
+ score += term.length * weight
1018
+ if (matched.length < 6 && !matched.includes(term)) matched.push(term)
1019
+ }
1020
+ }
1021
+ if (score <= 0) continue
1022
+ results.push({
1023
+ id: typeof entry.id === 'string' ? entry.id : '',
1024
+ score,
1025
+ level: score >= SIM_LEVEL_HIGH ? 'high' : (score >= SIM_LEVEL_MID ? 'mid' : 'low'),
1026
+ terms: matched,
1027
+ whenEmpty: fields.when.trim() === '',
1028
+ })
1029
+ }
1030
+ results.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
1031
+ return { terms, results, autoTotal }
1032
+ }
1033
+
1034
+ /* ---------------- F16: source-mode validation ---------------- */
1035
+
1036
+ /**
1037
+ * Live validation for the source editor: syntax plus the cheap shape check
1038
+ * the Host will apply anyway (a JSON object, not an array/scalar). It
1039
+ * deliberately does NOT try to re-implement the Host's normalisation — the
1040
+ * authoritative disclosures (dropped items, corrected options, truncation,
1041
+ * version refusal) come back from the save itself, so the panel can never
1042
+ * promise something the Host does not do.
1043
+ *
1044
+ * Past `maxChars` the live parse is skipped: parsing multi-megabyte text on
1045
+ * every keystroke would freeze the panel, and the save path still enforces
1046
+ * the same ceiling with a readable refusal.
1047
+ */
1048
+ function sourceParseVerdict(text, maxChars) {
1049
+ if (typeof text !== 'string' || text.trim() === '') return { ok: false, message: t('source.empty') }
1050
+ if (typeof maxChars === 'number' && maxChars > 0 && text.length > maxChars) {
1051
+ return { ok: false, paused: true, message: t('source.truncatedInput', { size: Math.max(1, Math.round(text.length / 10000)) }) }
1052
+ }
1053
+ let parsed = null
1054
+ try {
1055
+ parsed = JSON.parse(text)
1056
+ } catch (err) {
1057
+ return { ok: false, message: t('source.invalid', { detail: String(err && err.message ? err.message : err) }) }
1058
+ }
1059
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1060
+ return { ok: false, message: t('source.notObject') }
1061
+ }
1062
+ return {
1063
+ ok: true,
1064
+ message: t('source.valid', {
1065
+ entries: Array.isArray(parsed.entries) ? parsed.entries.length : 0,
1066
+ profiles: Array.isArray(parsed.profiles) ? parsed.profiles.length : 0,
1067
+ sets: Array.isArray(parsed.sets) && parsed.sets.length > 0 ? parsed.sets.length : 1,
1068
+ }),
1069
+ }
1070
+ }
1071
+
736
1072
  async function callHost(method, args) {
737
1073
  // A hung host used to pin the panel on 保存中… forever; fail visibly
738
1074
  // after a generous timeout instead.
@@ -998,7 +1334,33 @@ window.__ModuleLoader__.load({
998
1334
  // F3: bulk-select mode and the chosen entry ids in it.
999
1335
  const [batchMode, setBatchMode] = React.useState(false)
1000
1336
  const [selected, setSelected] = React.useState([])
1337
+ // F13: the set form (null = closed). One small inline form serves create,
1338
+ // rename and duplicate, because all three are "type a name and confirm".
1339
+ const [setForm, setSetForm] = React.useState(null)
1340
+ // F16: source mode holds its own draft + the rev the draft was read at.
1341
+ // Capturing the rev at READ time is what makes an external write in
1342
+ // between a conflict instead of a silent overwrite of someone else's edit.
1343
+ const [sourceMode, setSourceMode] = React.useState(false)
1344
+ const [sourceText, setSourceText] = React.useState('')
1345
+ const [sourceBaseRev, setSourceBaseRev] = React.useState(undefined)
1346
+ const [sourceVerdict, setSourceVerdict] = React.useState(null)
1347
+ // The body ceiling for the live check, mirrored into a ref so the
1348
+ // debounced effect never has to depend on the snapshot object.
1349
+ const sourceCeilingRef = React.useRef(0)
1350
+ // F17: the local relevance preview.
1351
+ const [simOpen, setSimOpen] = React.useState(false)
1352
+ const [simQuery, setSimQuery] = React.useState('')
1353
+ // F14: the history list is fetched lazily, on first expand.
1354
+ const [historyOpen, setHistoryOpen] = React.useState(false)
1355
+ const [history, setHistory] = React.useState(null)
1356
+ const [historyBusy, setHistoryBusy] = React.useState(false)
1357
+ // F18: the backup-location draft (focused-input protection like budget).
1358
+ const [backupText, setBackupText] = React.useState('')
1359
+ const backupTextRef = React.useRef('')
1360
+ backupTextRef.current = backupText
1361
+ const backupFocusedRef = React.useRef(false)
1001
1362
  const importInputRef = React.useRef(null)
1363
+ const restoreInputRef = React.useRef(null)
1002
1364
  // Whether the budget input is being edited right now. A live SSE push
1003
1365
  // from another window must not replace the number under the cursor;
1004
1366
  // the ref (not state) is read inside absorb so no re-render is needed.
@@ -1034,6 +1396,26 @@ window.__ModuleLoader__.load({
1034
1396
  return () => clearTimeout(timer)
1035
1397
  }, [undo])
1036
1398
 
1399
+ /**
1400
+ * F16 live validation, debounced: parsing on every keystroke would stall
1401
+ * the panel on a multi-megabyte paste, and 250 ms is below the threshold
1402
+ * where typing feels unsynced. The verdict is advisory — the authoritative
1403
+ * answer (including every correction the loader makes) comes from the
1404
+ * save itself.
1405
+ *
1406
+ * The ceiling comes through a ref, and the deps are only [sourceMode,
1407
+ * sourceText]: a new snapshot object arrives on every push (and the
1408
+ * ceiling never changes), so depending on the view would re-arm the timer
1409
+ * on every frame and the verdict would visibly lag the typing.
1410
+ */
1411
+ React.useEffect(() => {
1412
+ if (!sourceMode) return undefined
1413
+ const timer = setTimeout(() => {
1414
+ setSourceVerdict(sourceParseVerdict(sourceText, sourceCeilingRef.current))
1415
+ }, 250)
1416
+ return () => clearTimeout(timer)
1417
+ }, [sourceMode, sourceText])
1418
+
1037
1419
  /**
1038
1420
  * The rev of the snapshot this panel is currently rendering. Sent back as
1039
1421
  * baseRev on every mutation so the Host can refuse a write built on a
@@ -1042,7 +1424,12 @@ window.__ModuleLoader__.load({
1042
1424
  * Host whose snapshot carries no rev — JSON.stringify drops the key, so
1043
1425
  * the request stays byte-identical to what that Host expects.
1044
1426
  */
1045
- const revOf = () => (view !== null && view.data && typeof view.data.rev === 'number' ? view.data.rev : undefined)
1427
+ const revOfFrom = (snapshot) => (snapshot !== null && typeof snapshot === 'object'
1428
+ && snapshot.data !== null && typeof snapshot.data === 'object'
1429
+ && typeof snapshot.data.rev === 'number'
1430
+ ? snapshot.data.rev
1431
+ : undefined)
1432
+ const revOf = () => revOfFrom(view)
1046
1433
 
1047
1434
  function absorb(result) {
1048
1435
  const normalized = normalizeResult(result)
@@ -1059,6 +1446,11 @@ window.__ModuleLoader__.load({
1059
1446
  // ref is the live draft — absorb may run from the SSE closure created
1060
1447
  // on the first render.
1061
1448
  setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
1449
+ // The backup-location field follows the same rule as the budget input:
1450
+ // a push from another window never rewrites what is being typed.
1451
+ setBackupText(nextBudgetText(backupTextRef.current,
1452
+ typeof normalized.snapshot.data.backupDir === 'string' ? normalized.snapshot.data.backupDir : '',
1453
+ backupFocusedRef.current))
1062
1454
  if (!normalized.ok && normalized.message) {
1063
1455
  setError(normalized.message)
1064
1456
  setNotice('')
@@ -1163,6 +1555,180 @@ window.__ModuleLoader__.load({
1163
1555
  void send('delete-profile', { id: profile.id, baseRev: revOf() })
1164
1556
  }
1165
1557
 
1558
+ // ---- F13 instruction sets ----
1559
+
1560
+ /** Switch the active set. The one click that changes what is injected. */
1561
+ function switchSet(id) {
1562
+ if (busy || id === '') return
1563
+ void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
1564
+ // A switch invalidates any list selection: the ids belong to the set
1565
+ // that is no longer displayed.
1566
+ if (result !== null && result.ok === true) {
1567
+ setSelected([])
1568
+ setUndo(null)
1569
+ }
1570
+ })
1571
+ }
1572
+
1573
+ function submitSetForm() {
1574
+ if (setForm === null || busy) return
1575
+ const name = setForm.name.trim()
1576
+ if (name === '') {
1577
+ setError(t('sets.nameEmpty'))
1578
+ return
1579
+ }
1580
+ const call = setForm.mode === 'create'
1581
+ ? send('save-set', { set: { id: '', name }, baseRev: revOf() })
1582
+ : (setForm.mode === 'rename'
1583
+ ? send('save-set', { set: { id: setForm.id, name }, baseRev: revOf() })
1584
+ : send('duplicate-set', { id: setForm.id, name, baseRev: revOf() }))
1585
+ void call.then((result) => {
1586
+ if (result !== null && result.ok === true) setSetForm(null)
1587
+ })
1588
+ }
1589
+
1590
+ function removeSet(set) {
1591
+ if (busy) return
1592
+ if (set.active === true) {
1593
+ setError(t('sets.deleteActive'))
1594
+ return
1595
+ }
1596
+ if (typeof window.confirm === 'function'
1597
+ && !window.confirm(t('sets.deleteConfirm', { name: set.name, entries: set.entries, profiles: set.profiles }))) return
1598
+ void send('delete-set', { id: set.id, baseRev: revOf() })
1599
+ }
1600
+
1601
+ // ---- F14 version history ----
1602
+
1603
+ async function loadHistory() {
1604
+ setHistoryBusy(true)
1605
+ try {
1606
+ const result = await callHost('history-list', null)
1607
+ const normalized = normalizeResult(result)
1608
+ if (normalized === null || result.history === undefined || result.history === null) {
1609
+ setError(t('err.unrecognized'))
1610
+ return
1611
+ }
1612
+ setHistory({ dir: String(result.history.dir || ''), items: Array.isArray(result.history.items) ? result.history.items : [] })
1613
+ } catch (err) {
1614
+ setError(String(err && err.message ? err.message : err))
1615
+ } finally {
1616
+ setHistoryBusy(false)
1617
+ }
1618
+ }
1619
+
1620
+ function toggleHistory() {
1621
+ const next = !historyOpen
1622
+ setHistoryOpen(next)
1623
+ // Fetch on first expand (and on every expand after a write, so the list
1624
+ // matches the store the panel is showing).
1625
+ if (next) void loadHistory()
1626
+ }
1627
+
1628
+ function rollbackTo(item) {
1629
+ if (busy || typeof window.confirm !== 'function') return
1630
+ const when = new Date(item.epoch).toLocaleString()
1631
+ if (!window.confirm(t('history.restoreConfirm', { time: when, rev: item.rev }))) return
1632
+ void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
1633
+ if (result !== null && result.ok === true) void loadHistory()
1634
+ })
1635
+ }
1636
+
1637
+ function removeSnapshot(item) {
1638
+ if (busy) return
1639
+ if (typeof window.confirm === 'function' && !window.confirm(t('history.deleteConfirm'))) return
1640
+ void send('history-delete', { name: item.name }).then((result) => {
1641
+ if (result !== null && result.ok === true) void loadHistory()
1642
+ })
1643
+ }
1644
+
1645
+ // ---- F16 source mode ----
1646
+
1647
+ async function openSource() {
1648
+ if (busy) return
1649
+ if (draft !== null || profileDraft !== null) {
1650
+ setError(t('source.editorOpen'))
1651
+ return
1652
+ }
1653
+ try {
1654
+ const result = await callHost('read-source', null)
1655
+ if (result === null || typeof result !== 'object' || typeof result.source !== 'string') {
1656
+ // A refusal carries a reason (e.g. the file declares a newer format
1657
+ // version, so source mode is paused) — show THAT, not a generic
1658
+ // "unrecognized data".
1659
+ setError(result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
1660
+ ? result.message
1661
+ : t('err.unrecognized'))
1662
+ return
1663
+ }
1664
+ setSourceText(result.source)
1665
+ setSourceBaseRev(revOfFrom(result.snapshot))
1666
+ setSourceVerdict(null)
1667
+ setSourceMode(true)
1668
+ setShowPreview(false)
1669
+ setSimOpen(false)
1670
+ setError('')
1671
+ setNotice('')
1672
+ } catch (err) {
1673
+ setError(String(err && err.message ? err.message : err))
1674
+ }
1675
+ }
1676
+
1677
+ function reloadSource() {
1678
+ if (typeof window.confirm === 'function' && !window.confirm(t('source.reloadConfirm'))) return
1679
+ void openSource()
1680
+ }
1681
+
1682
+ function saveSource() {
1683
+ if (busy) return
1684
+ void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
1685
+ if (result === null || result.ok !== true) return
1686
+ // The draft now matches the committed store, so the rev it is based
1687
+ // on must move with it — otherwise the very next save would be
1688
+ // refused as a conflict against our own write.
1689
+ setSourceBaseRev(revOfFrom(result.snapshot))
1690
+ void loadHistory()
1691
+ })
1692
+ }
1693
+
1694
+ // ---- F18 backup location ----
1695
+
1696
+ function applyBackupDir() {
1697
+ if (busy) return
1698
+ void send('set-options', { backupDir: backupText, baseRev: revOf() })
1699
+ }
1700
+
1701
+ function exportToDir() {
1702
+ if (busy) return
1703
+ void send('export-to-dir', null)
1704
+ }
1705
+
1706
+ // ---- F14 whole-store restore (the "replace" door import does not open) ----
1707
+
1708
+ async function handleRestoreFile(file) {
1709
+ try {
1710
+ if (typeof window.confirm === 'function' && !window.confirm(t('restore.confirm'))) return
1711
+ const text = typeof file.text === 'function'
1712
+ ? await file.text()
1713
+ : await new Promise((resolve, reject) => {
1714
+ const reader = new FileReader()
1715
+ reader.onload = () => resolve(String(reader.result))
1716
+ reader.onerror = () => reject(reader.error)
1717
+ reader.readAsText(file)
1718
+ })
1719
+ let payload
1720
+ try {
1721
+ payload = JSON.parse(text)
1722
+ } catch (err) {
1723
+ setError(t('import.notJson', { detail: String(err && err.message ? err.message : err) }))
1724
+ return
1725
+ }
1726
+ await send('restore-data', { payload, baseRev: revOf() })
1727
+ } catch (err) {
1728
+ setError(String(err && err.message ? err.message : err))
1729
+ }
1730
+ }
1731
+
1166
1732
  async function doExport() {
1167
1733
  try {
1168
1734
  const result = await callHost('export-data', null)
@@ -1280,7 +1846,13 @@ window.__ModuleLoader__.load({
1280
1846
  // Older hosts can hand over snapshots without storage/limits; fall back
1281
1847
  // instead of throwing mid-render.
1282
1848
  const storage = view.storage || {}
1283
- const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
1849
+ const limits = view.limits || {
1850
+ minBudget: 800, maxBudget: 40000, maxEntries: 200, maxProfiles: 50,
1851
+ maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
1852
+ }
1853
+ // Mirrored for the debounced source check (see its effect above): reading
1854
+ // it here keeps the effect's dependency list down to the text itself.
1855
+ sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
1284
1856
  const injection = view.injection || { registered: false, chars: 0, error: null }
1285
1857
  // List order is display-only and never changes what the Host injects
1286
1858
  // (the injection order is decided host-side). The default mirrors the
@@ -1327,6 +1899,39 @@ window.__ModuleLoader__.load({
1327
1899
  // Older hosts simply publish none of these fields: the meter falls back
1328
1900
  // to data.budgetChars and the maps stay empty.
1329
1901
  const meter = budgetMeter(view)
1902
+ /**
1903
+ * F13: the set list. A newer Host publishes the derived block (with
1904
+ * per-set counts and the active flag); an older one only has
1905
+ * data.sets/activeSetId, so the fallback builds the same shape with
1906
+ * zeroed counts rather than hiding the switcher.
1907
+ */
1908
+ const setsBlock = view.sets !== null && typeof view.sets === 'object' ? view.sets : null
1909
+ // A Host from before F13 publishes neither sets nor activeSetId; the set
1910
+ // UI is then simply absent instead of showing a made-up single set.
1911
+ const setsCapable = typeof data.activeSetId === 'string' && data.activeSetId !== ''
1912
+ const fallbackSets = setsCapable
1913
+ ? (Array.isArray(data.sets) && data.sets.length > 0
1914
+ ? data.sets
1915
+ : [{ id: data.activeSetId, name: t('sets.section') }])
1916
+ : []
1917
+ const setsList = setsBlock !== null && Array.isArray(setsBlock.list)
1918
+ ? setsBlock.list
1919
+ : fallbackSets.map((set) => ({
1920
+ id: set.id,
1921
+ name: set.name,
1922
+ active: set.id === data.activeSetId,
1923
+ entries: set.id === data.activeSetId ? data.entries.length : 0,
1924
+ profiles: set.id === data.activeSetId ? data.profiles.length : 0,
1925
+ }))
1926
+ const setsActiveId = setsBlock !== null && typeof setsBlock.activeId === 'string' ? setsBlock.activeId : data.activeSetId
1927
+ const setTotalEntries = setsBlock !== null && setsBlock.total && typeof setsBlock.total.entries === 'number'
1928
+ ? setsBlock.total.entries
1929
+ : data.entries.length
1930
+ const activeSetName = (setsList.find((set) => set.id === setsActiveId) || {}).name || ''
1931
+ // F17: the local preview scores the ACTIVE set's on-demand entries.
1932
+ const autoEntries = data.entries.filter((entry) => entry.mode === 'auto')
1933
+ const sim = relevancePreview(autoEntries, simQuery)
1934
+ const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
1330
1935
  const omittedById = new Map()
1331
1936
  const truncatedById = new Map()
1332
1937
  {
@@ -1429,6 +2034,70 @@ window.__ModuleLoader__.load({
1429
2034
  ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
1430
2035
  : null)
1431
2036
  : null,
2037
+ // ---- F13 配置集:列表 + 切换(唯一会改变注入内容的动作) ----
2038
+ setsList.length > 0
2039
+ ? h('div', { key: 'sets', className: 'im-sets' },
2040
+ h('div', { className: 'im-sets-row' },
2041
+ h('span', { className: 'im-section-title' }, t('sets.section')),
2042
+ h('span', { className: 'im-spacer' }),
2043
+ h('span', { className: 'im-meta' }, t('sets.total', { total: setTotalEntries, max: limits.maxEntries })),
2044
+ h('button', {
2045
+ className: 'im-btn',
2046
+ disabled: busy || setsList.length >= limits.maxSets,
2047
+ onClick: () => { setSetForm({ mode: 'create', id: '', name: '' }); setError('') },
2048
+ }, t('sets.create'))),
2049
+ h('div', { className: 'im-meta' }, t('sets.note')),
2050
+ setsList.map((set) => h('div', { className: 'im-sets-row', key: set.id },
2051
+ set.active === true
2052
+ ? h('span', { className: 'im-active-dot', title: t('sets.activeHint', { name: set.name }) })
2053
+ : h('span', { className: 'im-dot' }),
2054
+ h('span', { className: 'im-title' }, set.name),
2055
+ h('span', { className: 'im-meta' }, t('sets.itemMeta', { entries: set.entries, profiles: set.profiles })),
2056
+ set.active === true
2057
+ ? h('span', { className: 'im-tag im-tag-always' }, t('sets.activeBadge'))
2058
+ : null,
2059
+ h('span', { className: 'im-spacer' }),
2060
+ set.active === true
2061
+ ? null
2062
+ : h('button', {
2063
+ className: 'im-btn im-btn-primary',
2064
+ disabled: busy,
2065
+ title: t('sets.switchHint'),
2066
+ onClick: () => switchSet(set.id),
2067
+ }, t('sets.switch')),
2068
+ h('button', {
2069
+ className: 'im-btn',
2070
+ disabled: busy,
2071
+ onClick: () => { setSetForm({ mode: 'rename', id: set.id, name: set.name }); setError('') },
2072
+ }, t('sets.rename')),
2073
+ h('button', {
2074
+ className: 'im-btn',
2075
+ disabled: busy || setTotalEntries + set.entries > limits.maxEntries,
2076
+ onClick: () => { setSetForm({ mode: 'duplicate', id: set.id, name: set.name + t('sets.copySuffix') }); setError('') },
2077
+ }, t('sets.duplicate')),
2078
+ set.active === true
2079
+ ? null
2080
+ : h('button', {
2081
+ className: 'im-btn im-btn-danger',
2082
+ disabled: busy,
2083
+ onClick: () => removeSet(set),
2084
+ }, t('sets.delete')))),
2085
+ setForm !== null
2086
+ ? h('div', { className: 'im-inline-form' },
2087
+ h('span', { className: 'im-meta' }, t('sets.nameLabel')),
2088
+ h('input', {
2089
+ className: 'im-input',
2090
+ value: setForm.name,
2091
+ maxLength: limits.maxSetName,
2092
+ disabled: busy,
2093
+ placeholder: t('sets.namePlaceholder'),
2094
+ onChange: (ev) => setSetForm({ mode: setForm.mode, id: setForm.id, name: ev.target.value }),
2095
+ onKeyDown: (ev) => { if (ev.key === 'Enter') submitSetForm() },
2096
+ }),
2097
+ h('button', { className: 'im-btn im-btn-primary', disabled: busy, onClick: submitSetForm }, t('sets.confirm')),
2098
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setSetForm(null) }, t('editor.cancel')))
2099
+ : null)
2100
+ : null,
1432
2101
  // ---- User persona ("用户形象"): facts about the USER, not a role ----
1433
2102
  h('div', { key: 'profiles', className: 'im-profiles' },
1434
2103
  h('div', { className: 'im-profiles-head' },
@@ -1532,6 +2201,49 @@ window.__ModuleLoader__.load({
1532
2201
  }, t('batch.delete')),
1533
2202
  h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
1534
2203
  : null,
2204
+ // ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
2205
+ (data.enabled === true && autoEntries.length > 0)
2206
+ ? h('div', { key: 'sim', className: 'im-profiles' },
2207
+ h('div', { className: 'im-profiles-head' },
2208
+ h('span', { className: 'im-section-title' }, t('sim.title')),
2209
+ h('span', { className: 'im-spacer' }),
2210
+ h('button', {
2211
+ className: 'im-btn',
2212
+ disabled: busy,
2213
+ onClick: () => setSimOpen(!simOpen),
2214
+ }, simOpen ? t('sim.hide') : t('sim.toggle'))),
2215
+ simOpen
2216
+ ? h('div', null,
2217
+ // The disclaimer is deliberately NOT small print in grey: it is
2218
+ // a warning that this is a heuristic, so it reads as one.
2219
+ h('div', { className: 'im-warn im-profile-sub' }, t('sim.disclaimer')),
2220
+ h('input', {
2221
+ className: 'im-input',
2222
+ value: simQuery,
2223
+ disabled: busy,
2224
+ placeholder: t('sim.placeholder'),
2225
+ onChange: (ev) => setSimQuery(ev.target.value),
2226
+ }),
2227
+ h('div', { className: 'im-meta im-meter-line' }, t('sim.onlyAuto')),
2228
+ simQuery.trim() === ''
2229
+ ? h('div', { className: 'im-meta im-meter-line' }, t('sim.hint'))
2230
+ : h('div', { className: 'im-sim-result' },
2231
+ h('div', { className: 'im-meta' }, t('sim.summary', { total: sim.autoTotal, hit: sim.results.length })),
2232
+ sim.results.length === 0
2233
+ ? h('div', { className: 'im-meta' }, t('sim.none'))
2234
+ : sim.results.slice(0, 8).map((hit) => {
2235
+ const entry = data.entries.find((candidate) => candidate.id === hit.id)
2236
+ if (entry === undefined) return null
2237
+ return h('div', { className: 'im-sim-item', key: hit.id },
2238
+ h('span', { className: 'im-title' }, entry.title !== '' ? entry.title : t('entry.untitled')),
2239
+ h('span', { className: 'im-tag im-tag-sim-' + hit.level },
2240
+ hit.level === 'high' ? t('sim.levelHigh') : (hit.level === 'mid' ? t('sim.levelMid') : t('sim.levelLow'))),
2241
+ h('span', { className: 'im-meta' }, t('sim.matched', { terms: hit.terms.join(' / ') })),
2242
+ hit.whenEmpty ? h('span', { className: 'im-warn' }, t('sim.noWhen')) : null,
2243
+ omittedById.has(hit.id) ? h('span', { className: 'im-tag im-tag-off' }, t('sim.omitted')) : null)
2244
+ })))
2245
+ : null)
2246
+ : null,
1535
2247
  // F2 filters: tag chips (only when tags exist) plus mode/status/sort.
1536
2248
  entries.length > 0
1537
2249
  ? h('div', { key: 'filters', className: 'im-filter-row' },
@@ -1605,6 +2317,20 @@ window.__ModuleLoader__.load({
1605
2317
  if (el !== null && el !== undefined) el.click()
1606
2318
  },
1607
2319
  }, t('action.import')),
2320
+ h('button', {
2321
+ className: 'im-btn',
2322
+ disabled: busy,
2323
+ title: t('restore.hint'),
2324
+ onClick: () => {
2325
+ const el = restoreInputRef.current
2326
+ if (el !== null && el !== undefined) el.click()
2327
+ },
2328
+ }, t('action.restore')),
2329
+ h('button', {
2330
+ className: 'im-btn',
2331
+ disabled: busy || draft !== null || profileDraft !== null,
2332
+ onClick: () => { void openSource() },
2333
+ }, t('source.mode')),
1608
2334
  h('button', {
1609
2335
  className: 'im-btn',
1610
2336
  disabled: busy,
@@ -1626,6 +2352,64 @@ window.__ModuleLoader__.load({
1626
2352
  ? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
1627
2353
  : null,
1628
2354
  storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
2355
+ // ---- F18 备份位置 ----
2356
+ h('div', { key: 'backup', className: 'im-backup' },
2357
+ h('div', { className: 'im-section-title' }, t('backup.section')),
2358
+ h('label', { className: 'im-label' }, t('backup.label')),
2359
+ h('div', { className: 'im-backup-row' },
2360
+ h('input', {
2361
+ className: 'im-input',
2362
+ value: backupText,
2363
+ disabled: busy,
2364
+ maxLength: limits.maxBackupDir,
2365
+ placeholder: t('backup.placeholder'),
2366
+ onFocus: () => { backupFocusedRef.current = true },
2367
+ onChange: (ev) => setBackupText(ev.target.value),
2368
+ onBlur: () => { backupFocusedRef.current = false },
2369
+ }),
2370
+ h('button', { className: 'im-btn im-btn-primary', disabled: busy, onClick: applyBackupDir }, t('backup.apply')),
2371
+ h('button', { className: 'im-btn', disabled: busy, onClick: exportToDir }, t('backup.exportToDir'))),
2372
+ h('div', { className: 'im-meta' }, t('backup.hint')),
2373
+ h('div', { className: 'im-meta' }, t('backup.multiDevice'))),
2374
+ // ---- F14 版本历史 ----
2375
+ h('div', { key: 'history', className: 'im-history' },
2376
+ h('div', { className: 'im-profiles-head' },
2377
+ h('span', { className: 'im-section-title' }, t('history.section')),
2378
+ h('span', { className: 'im-spacer' }),
2379
+ history !== null ? h('span', { className: 'im-meta' }, t('history.count', { count: history.items.length })) : null,
2380
+ h('button', { className: 'im-btn', disabled: busy, onClick: toggleHistory }, historyOpen ? t('history.hide') : t('history.show'))),
2381
+ h('div', { className: 'im-meta im-profile-sub' }, t('history.subtitle', { max: limits.maxHistory })),
2382
+ historyOpen
2383
+ ? (historyBusy && history === null
2384
+ ? h('div', { className: 'im-meta' }, t('history.loading'))
2385
+ : (history === null || history.items.length === 0
2386
+ ? h('div', { className: 'im-meta' }, t('history.empty'))
2387
+ : h('div', { className: 'im-list-scroll' }, history.items.map((item) => {
2388
+ const when = new Date(item.epoch).toLocaleString()
2389
+ const size = Math.max(1, Math.round(item.bytes / 1024))
2390
+ return h('div', {
2391
+ className: 'im-history-item' + (item.rev === data.rev ? ' im-history-item-current' : ''),
2392
+ key: item.name,
2393
+ },
2394
+ h('span', { className: 'im-title' }, when),
2395
+ h('span', { className: 'im-tag' }, 'rev ' + item.rev),
2396
+ h('span', { className: 'im-history-sub' },
2397
+ item.entries === null
2398
+ ? t('history.itemMetaLite', { size })
2399
+ : t('history.itemMeta', {
2400
+ entries: item.entries,
2401
+ profiles: item.profiles === null ? 0 : item.profiles,
2402
+ size,
2403
+ })),
2404
+ item.foreign === true ? h('span', { className: 'im-tag im-tag-off' }, t('history.foreign', { device: item.device })) : null,
2405
+ h('span', { className: 'im-spacer' }),
2406
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => rollbackTo(item) }, t('history.restore')),
2407
+ h('button', { className: 'im-btn im-btn-danger', disabled: busy, onClick: () => removeSnapshot(item) }, t('history.delete')))
2408
+ }))))
2409
+ : null,
2410
+ history !== null && history.dir !== ''
2411
+ ? h('div', { className: 'im-meta' }, t('history.dir', { dir: history.dir }))
2412
+ : null),
1629
2413
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
1630
2414
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
1631
2415
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
@@ -1660,8 +2444,73 @@ window.__ModuleLoader__.load({
1660
2444
  ev.target.value = ''
1661
2445
  if (file !== null) void handleImportFile(file)
1662
2446
  },
2447
+ }),
2448
+ // F14: the whole-store restore picker. A separate input because its
2449
+ // confirmation and its semantics (replace, not merge) differ from import.
2450
+ h('input', {
2451
+ key: 'restore-input',
2452
+ type: 'file',
2453
+ accept: '.json,application/json',
2454
+ style: { display: 'none' },
2455
+ ref: restoreInputRef,
2456
+ onChange: (ev) => {
2457
+ const file = ev.target.files && ev.target.files.length > 0 ? ev.target.files[0] : null
2458
+ ev.target.value = ''
2459
+ if (file !== null) void handleRestoreFile(file)
2460
+ },
1663
2461
  }))
1664
2462
 
2463
+ /**
2464
+ * F16 源码模式 replaces the list area entirely: editing the raw JSON and
2465
+ * editing the cards are two views of ONE store, so they are never shown
2466
+ * at once (and the entry/persona editors must be closed first — see
2467
+ * openSource). The header card stays visible so the master switch, the
2468
+ * budget and the set switcher remain reachable.
2469
+ */
2470
+ if (sourceMode) {
2471
+ const canSave = sourceVerdict === null || sourceVerdict.ok === true || sourceVerdict.paused === true
2472
+ return h('div', { className: 'im-root' },
2473
+ header,
2474
+ h('div', { className: 'im-card' },
2475
+ h('div', { className: 'im-profiles-head' },
2476
+ h('span', { className: 'im-section-title' }, t('source.section')),
2477
+ h('span', { className: 'im-spacer' }),
2478
+ sourceBaseRev !== undefined
2479
+ ? h('span', { className: 'im-meta' }, 'rev ' + sourceBaseRev)
2480
+ : null),
2481
+ h('div', { className: 'im-meta' }, t('source.subtitle')),
2482
+ h('div', { className: 'im-source' },
2483
+ h('textarea', {
2484
+ className: 'im-textarea im-textarea-source',
2485
+ value: sourceText,
2486
+ disabled: busy,
2487
+ spellCheck: false,
2488
+ onChange: (ev) => setSourceText(ev.target.value),
2489
+ }),
2490
+ sourceVerdict !== null
2491
+ ? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
2492
+ : null,
2493
+ error !== '' ? h('div', { className: 'im-err' }, error) : null,
2494
+ // Saving can be refused (a conflict, a future version, a shape
2495
+ // problem). The draft is never thrown away by a refusal, and the
2496
+ // user needs to know that before deciding what to do next.
2497
+ error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
2498
+ error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
2499
+ h('div', { className: 'im-row-wrap' },
2500
+ h('button', {
2501
+ className: 'im-btn im-btn-primary',
2502
+ disabled: busy || !canSave,
2503
+ onClick: saveSource,
2504
+ }, busy ? t('editor.saving') : t('source.save')),
2505
+ h('button', { className: 'im-btn', disabled: busy, onClick: reloadSource }, t('source.reload')),
2506
+ h('span', { className: 'im-spacer' }),
2507
+ h('button', {
2508
+ className: 'im-btn',
2509
+ disabled: busy,
2510
+ onClick: () => { setSourceMode(false); setSourceVerdict(null); setError(''); setNotice('') },
2511
+ }, t('source.exit'))))))
2512
+ }
2513
+
1665
2514
  const items = visible.map((entry) => {
1666
2515
  const tags = [
1667
2516
  h('span', {
@@ -1682,6 +2531,16 @@ window.__ModuleLoader__.load({
1682
2531
  className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
1683
2532
  }, t('tag.truncated')))
1684
2533
  }
2534
+ // F17: the local preview's literal overlap, shown only while the
2535
+ // preview is open so the mark always comes with its disclaimer.
2536
+ const simHit = simOpen ? simById.get(entry.id) : undefined
2537
+ if (simHit !== undefined) {
2538
+ tags.push(h('span', {
2539
+ className: 'im-tag im-tag-sim-' + simHit.level,
2540
+ key: 'sim',
2541
+ title: t('sim.matched', { terms: simHit.terms.join(' / ') }),
2542
+ }, simHit.level === 'high' ? t('sim.levelHigh') : (simHit.level === 'mid' ? t('sim.levelMid') : t('sim.levelLow'))))
2543
+ }
1685
2544
  return h('div', {
1686
2545
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
1687
2546
  key: entry.id,
@@ -1855,6 +2714,9 @@ window.__ModuleLoader__.load({
1855
2714
  exports.__nextBudgetText = nextBudgetText
1856
2715
  exports.__budgetMeter = budgetMeter
1857
2716
  exports.__parseTagInput = parseTagInput
2717
+ exports.__relevanceTerms = relevanceTerms
2718
+ exports.__relevancePreview = relevancePreview
2719
+ exports.__sourceParseVerdict = sourceParseVerdict
1858
2720
  exports.__strings = STRINGS
1859
2721
  exports.__ns = NS
1860
2722
  exports.__dictionaryT = dictionaryT