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/CHANGELOG.md +168 -0
- package/README.md +208 -14
- package/lib/client.js +1267 -10
- package/lib/index.js +1645 -90
- package/memory.schema.json +173 -0
- package/package.json +3 -2
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
|
|
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 || {
|
|
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('
|
|
1377
|
-
h('
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|