dsh-instruction-memory 1.0.18 → 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/CHANGELOG.md +139 -0
- package/README.md +191 -13
- package/lib/client.js +1082 -5
- package/lib/index.js +1642 -79
- package/memory.schema.json +167 -0
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -83,6 +83,15 @@ window.__ModuleLoader__.load({
|
|
|
83
83
|
.im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
|
|
84
84
|
border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
|
|
85
85
|
.im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
86
|
+
.im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
|
|
87
|
+
background:var(--dsw-alias-bg-layer-1,#fff); }
|
|
88
|
+
.im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
89
|
+
.im-profile-item { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:9px; padding:9px 11px;
|
|
90
|
+
margin-top:8px; background:var(--dsw-alias-bg-base,#fff); }
|
|
91
|
+
.im-profile-item-off { opacity:.55; }
|
|
92
|
+
.im-profile-name { font-weight:600; font-size:13.5px; }
|
|
93
|
+
.im-profile-sub { margin-top:3px; }
|
|
94
|
+
.im-section-title { font-weight:600; font-size:13px; }
|
|
86
95
|
.im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
|
|
87
96
|
border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
88
97
|
.im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
|
|
@@ -109,6 +118,41 @@ window.__ModuleLoader__.load({
|
|
|
109
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)); }
|
|
110
119
|
.im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
|
|
111
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; }
|
|
112
156
|
@media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
|
|
113
157
|
`
|
|
114
158
|
|
|
@@ -207,6 +251,20 @@ window.__ModuleLoader__.load({
|
|
|
207
251
|
'batch.tagEmpty': '未输入有效的标签',
|
|
208
252
|
'batch.noneSelected': '请先勾选要操作的指令',
|
|
209
253
|
'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
|
|
254
|
+
'profiles.section': '用户形象',
|
|
255
|
+
'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
|
|
256
|
+
'profiles.new': '+ 新增形象',
|
|
257
|
+
'profiles.name': '形象名称',
|
|
258
|
+
'profiles.namePlaceholder': '例如:职业背景 / 沟通偏好 / 性格 / 长期追求',
|
|
259
|
+
'profiles.content': '形象内容',
|
|
260
|
+
'profiles.contentPlaceholder': '描述你本人的情况,例如:有 8 年后端开发经验,技术问题可跳过入门概念;喜欢先给结论、再给可执行步骤,少寒暄。',
|
|
261
|
+
'profiles.use': '使用该形象',
|
|
262
|
+
'profiles.untitled': '未命名形象',
|
|
263
|
+
'profiles.confirmDelete': '确定删除用户形象「{name}」?删除后无法恢复。',
|
|
264
|
+
'profiles.empty': '还没有用户形象。点击「+ 新增形象」添加关于你本人的信息;不勾选任何形象时,AI 完全按原本方式回答。',
|
|
265
|
+
'profiles.count': '共 {total} 个,使用中 {active} 个',
|
|
266
|
+
'profiles.chars': '已启用形象合计 {count} 字符(独立于指令字数上限,完整注入、不做裁剪)',
|
|
267
|
+
'profiles.saving': '保存中…',
|
|
210
268
|
'action.new': '+ 新增指令',
|
|
211
269
|
'action.reload': '重新载入',
|
|
212
270
|
'action.export': '导出',
|
|
@@ -237,6 +295,84 @@ window.__ModuleLoader__.load({
|
|
|
237
295
|
'undo.restore': '撤销删除',
|
|
238
296
|
'undo.dismiss': '知道了',
|
|
239
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 与版本历史中;配置集、条目与用户形象都会被文件内容替换(备份位置等本机设置保持不变)。',
|
|
240
376
|
},
|
|
241
377
|
en: {
|
|
242
378
|
'section.label': 'Instruction Memory',
|
|
@@ -322,6 +458,20 @@ window.__ModuleLoader__.load({
|
|
|
322
458
|
'batch.tagEmpty': 'No valid tag entered',
|
|
323
459
|
'batch.noneSelected': 'Tick the entries to act on first',
|
|
324
460
|
'batch.promptUnavailable': 'Quick entry is unavailable in this environment; edit tags one by one',
|
|
461
|
+
'profiles.section': 'User persona',
|
|
462
|
+
'profiles.subtitle': 'Facts about you: background, tastes, personality and goals. When “Use this persona” is ticked, the AI adjusts depth, examples and advice to tilt toward you — while staying correct and on-topic; it must never change facts or wander off the question to please you.',
|
|
463
|
+
'profiles.new': '+ New persona',
|
|
464
|
+
'profiles.name': 'Persona name',
|
|
465
|
+
'profiles.namePlaceholder': 'e.g. Professional background / Communication style / Personality / Goals',
|
|
466
|
+
'profiles.content': 'Persona details',
|
|
467
|
+
'profiles.contentPlaceholder': 'Describe yourself, e.g. 8 years of backend experience, so skip intro concepts; prefers the conclusion first, then actionable steps, no small talk.',
|
|
468
|
+
'profiles.use': 'Use this persona',
|
|
469
|
+
'profiles.untitled': 'Untitled persona',
|
|
470
|
+
'profiles.confirmDelete': 'Delete the persona “{name}”? This cannot be undone.',
|
|
471
|
+
'profiles.empty': 'No persona yet. Click “+ New persona” to add facts about yourself; with no persona ticked, the AI answers exactly as it normally would.',
|
|
472
|
+
'profiles.count': '{total} in total, {active} in use',
|
|
473
|
+
'profiles.chars': 'Active personas total {count} characters (separate from the instruction budget; injected in full, never trimmed)',
|
|
474
|
+
'profiles.saving': 'Saving…',
|
|
325
475
|
'action.new': '+ New instruction',
|
|
326
476
|
'action.reload': 'Reload',
|
|
327
477
|
'action.export': 'Export',
|
|
@@ -352,6 +502,84 @@ window.__ModuleLoader__.load({
|
|
|
352
502
|
'undo.restore': 'Undo delete',
|
|
353
503
|
'undo.dismiss': 'Dismiss',
|
|
354
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).',
|
|
355
583
|
},
|
|
356
584
|
}
|
|
357
585
|
|
|
@@ -519,6 +747,30 @@ window.__ModuleLoader__.load({
|
|
|
519
747
|
}
|
|
520
748
|
}
|
|
521
749
|
|
|
750
|
+
// ---- User persona ("用户形象") drafts ----
|
|
751
|
+
function cloneProfile(profile) {
|
|
752
|
+
return {
|
|
753
|
+
id: profile.id,
|
|
754
|
+
name: profile.name,
|
|
755
|
+
content: profile.content,
|
|
756
|
+
enabled: profile.enabled === true,
|
|
757
|
+
updatedAt: profile.updatedAt,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function blankProfileDraft() {
|
|
762
|
+
return { id: '', name: '', content: '', enabled: true, updatedAt: 0 }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Whether a profile editor differs from the stored version (or a blank new one). */
|
|
766
|
+
function profileIsDirty(draft, profiles) {
|
|
767
|
+
const base = draft.id === ''
|
|
768
|
+
? blankProfileDraft()
|
|
769
|
+
: (profiles || []).find((profile) => profile.id === draft.id)
|
|
770
|
+
if (base === undefined) return true
|
|
771
|
+
return base.name !== draft.name || base.content !== draft.content || base.enabled !== draft.enabled
|
|
772
|
+
}
|
|
773
|
+
|
|
522
774
|
function blankDraft() {
|
|
523
775
|
return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
|
|
524
776
|
}
|
|
@@ -650,7 +902,14 @@ window.__ModuleLoader__.load({
|
|
|
650
902
|
const budget = typeof inj.budget === 'number' && inj.budget > 0
|
|
651
903
|
? inj.budget
|
|
652
904
|
: (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
|
|
653
|
-
|
|
905
|
+
// Measure ONLY the instruction half: on a host that publishes persona
|
|
906
|
+
// profiles, `chars` is the whole section (personas + instructions) while
|
|
907
|
+
// `entryChars` is the budgeted half. Older hosts publish neither field,
|
|
908
|
+
// and there `chars` is already instructions-only, so the fallback is
|
|
909
|
+
// correct on both.
|
|
910
|
+
const used = typeof inj.entryChars === 'number' && inj.entryChars >= 0
|
|
911
|
+
? inj.entryChars
|
|
912
|
+
: (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
|
|
654
913
|
const countFlagged = (list, wantTruncated) =>
|
|
655
914
|
Array.isArray(list)
|
|
656
915
|
? list.filter((item) => item !== null && typeof item === 'object'
|
|
@@ -665,6 +924,151 @@ window.__ModuleLoader__.load({
|
|
|
665
924
|
return { budget, used, omitted, truncated, percent, tokens }
|
|
666
925
|
}
|
|
667
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
|
+
|
|
668
1072
|
async function callHost(method, args) {
|
|
669
1073
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
670
1074
|
// after a generous timeout instead.
|
|
@@ -860,12 +1264,60 @@ window.__ModuleLoader__.load({
|
|
|
860
1264
|
/** How long the undo bar keeps its offer alive after a confirmed delete. */
|
|
861
1265
|
const UNDO_TTL_MS = 10000
|
|
862
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* Editor for one "用户形象" (facts about the user). Deliberately minimal:
|
|
1269
|
+
* a single-line name and a multi-line description. Save is disabled while
|
|
1270
|
+
* both are blank (the Host would reject that anyway). The enable toggle
|
|
1271
|
+
* lives on the card, not here: the editor edits content, the card decides
|
|
1272
|
+
* whether the persona is used.
|
|
1273
|
+
*/
|
|
1274
|
+
function ProfileEditor(props) {
|
|
1275
|
+
const limits = props.limits || {}
|
|
1276
|
+
const maxName = typeof limits.maxProfileName === 'number' ? limits.maxProfileName : 40
|
|
1277
|
+
const maxContent = typeof limits.maxProfileContent === 'number' ? limits.maxProfileContent : 4000
|
|
1278
|
+
const set = (patch) => props.onChange(Object.assign({}, props.draft, patch))
|
|
1279
|
+
const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
|
|
1280
|
+
return h('div', { className: 'im-editor im-profiles' },
|
|
1281
|
+
h('div', null,
|
|
1282
|
+
h('label', { className: 'im-label' }, t('profiles.name')),
|
|
1283
|
+
h('input', {
|
|
1284
|
+
className: 'im-input',
|
|
1285
|
+
value: props.draft.name,
|
|
1286
|
+
maxLength: maxName,
|
|
1287
|
+
placeholder: t('profiles.namePlaceholder'),
|
|
1288
|
+
disabled: props.busy,
|
|
1289
|
+
onChange: (ev) => set({ name: ev.target.value }),
|
|
1290
|
+
})),
|
|
1291
|
+
h('div', null,
|
|
1292
|
+
h('label', { className: 'im-label' }, t('profiles.content')),
|
|
1293
|
+
h('textarea', {
|
|
1294
|
+
className: 'im-textarea',
|
|
1295
|
+
rows: 6,
|
|
1296
|
+
value: props.draft.content,
|
|
1297
|
+
maxLength: maxContent,
|
|
1298
|
+
placeholder: t('profiles.contentPlaceholder'),
|
|
1299
|
+
disabled: props.busy,
|
|
1300
|
+
onChange: (ev) => set({ content: ev.target.value }),
|
|
1301
|
+
})),
|
|
1302
|
+
h('div', { className: 'im-row-wrap' },
|
|
1303
|
+
h('button', {
|
|
1304
|
+
className: 'im-btn im-btn-primary',
|
|
1305
|
+
disabled: props.busy || !canSave,
|
|
1306
|
+
onClick: props.onSave,
|
|
1307
|
+
}, props.busy ? t('profiles.saving') : t('editor.save')),
|
|
1308
|
+
h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, t('editor.cancel'))))
|
|
1309
|
+
}
|
|
1310
|
+
|
|
863
1311
|
function InstructionPanel() {
|
|
864
1312
|
const [view, setView] = React.useState(null)
|
|
865
1313
|
const [error, setError] = React.useState('')
|
|
866
1314
|
const [notice, setNotice] = React.useState('')
|
|
867
1315
|
const [busy, setBusy] = React.useState(false)
|
|
868
1316
|
const [draft, setDraft] = React.useState(null)
|
|
1317
|
+
// The persona editor draft (null = closed). Independent of the entry
|
|
1318
|
+
// draft: editing an instruction never closes an open persona and vice
|
|
1319
|
+
// versa is handled by closing one when opening the other (see handlers).
|
|
1320
|
+
const [profileDraft, setProfileDraft] = React.useState(null)
|
|
869
1321
|
const [budgetText, setBudgetText] = React.useState('')
|
|
870
1322
|
// Latest live value of the draft, because the SSE onmessage closure below
|
|
871
1323
|
// is created once and must not read a stale first-render budgetText.
|
|
@@ -882,7 +1334,33 @@ window.__ModuleLoader__.load({
|
|
|
882
1334
|
// F3: bulk-select mode and the chosen entry ids in it.
|
|
883
1335
|
const [batchMode, setBatchMode] = React.useState(false)
|
|
884
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)
|
|
885
1362
|
const importInputRef = React.useRef(null)
|
|
1363
|
+
const restoreInputRef = React.useRef(null)
|
|
886
1364
|
// Whether the budget input is being edited right now. A live SSE push
|
|
887
1365
|
// from another window must not replace the number under the cursor;
|
|
888
1366
|
// the ref (not state) is read inside absorb so no re-render is needed.
|
|
@@ -918,6 +1396,26 @@ window.__ModuleLoader__.load({
|
|
|
918
1396
|
return () => clearTimeout(timer)
|
|
919
1397
|
}, [undo])
|
|
920
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
|
+
|
|
921
1419
|
/**
|
|
922
1420
|
* The rev of the snapshot this panel is currently rendering. Sent back as
|
|
923
1421
|
* baseRev on every mutation so the Host can refuse a write built on a
|
|
@@ -926,7 +1424,12 @@ window.__ModuleLoader__.load({
|
|
|
926
1424
|
* Host whose snapshot carries no rev — JSON.stringify drops the key, so
|
|
927
1425
|
* the request stays byte-identical to what that Host expects.
|
|
928
1426
|
*/
|
|
929
|
-
const
|
|
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)
|
|
930
1433
|
|
|
931
1434
|
function absorb(result) {
|
|
932
1435
|
const normalized = normalizeResult(result)
|
|
@@ -943,6 +1446,11 @@ window.__ModuleLoader__.load({
|
|
|
943
1446
|
// ref is the live draft — absorb may run from the SSE closure created
|
|
944
1447
|
// on the first render.
|
|
945
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))
|
|
946
1454
|
if (!normalized.ok && normalized.message) {
|
|
947
1455
|
setError(normalized.message)
|
|
948
1456
|
setNotice('')
|
|
@@ -1014,6 +1522,213 @@ window.__ModuleLoader__.load({
|
|
|
1014
1522
|
void doBatch('add-tags', tags)
|
|
1015
1523
|
}
|
|
1016
1524
|
|
|
1525
|
+
// ---- User persona ("用户形象") handlers ----
|
|
1526
|
+
|
|
1527
|
+
async function saveProfileDraft() {
|
|
1528
|
+
if (profileDraft === null) return
|
|
1529
|
+
if (profileDraft.name.trim() === '' && profileDraft.content.trim() === '') return
|
|
1530
|
+
const result = await send('save-profile', { profile: cloneProfile(profileDraft), baseRev: revOf() })
|
|
1531
|
+
if (result !== null && result.ok === true) setProfileDraft(null)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function toggleProfile(profile) {
|
|
1535
|
+
if (busy) return
|
|
1536
|
+
const next = cloneProfile(profile)
|
|
1537
|
+
next.enabled = !profile.enabled
|
|
1538
|
+
void send('save-profile', { profile: next, baseRev: revOf() })
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function editProfile(profile) {
|
|
1542
|
+
if (busy) return
|
|
1543
|
+
// The two editors are mutually exclusive so the panel stays legible.
|
|
1544
|
+
setDraft(null)
|
|
1545
|
+
setProfileDraft(cloneProfile(profile))
|
|
1546
|
+
setError('')
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function removeProfile(profile) {
|
|
1550
|
+
if (busy) return
|
|
1551
|
+
if (typeof window.confirm === 'function'
|
|
1552
|
+
&& !window.confirm(t('profiles.confirmDelete', {
|
|
1553
|
+
name: profile.name !== '' ? profile.name : t('profiles.untitled'),
|
|
1554
|
+
}))) return
|
|
1555
|
+
void send('delete-profile', { id: profile.id, baseRev: revOf() })
|
|
1556
|
+
}
|
|
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
|
+
|
|
1017
1732
|
async function doExport() {
|
|
1018
1733
|
try {
|
|
1019
1734
|
const result = await callHost('export-data', null)
|
|
@@ -1131,7 +1846,13 @@ window.__ModuleLoader__.load({
|
|
|
1131
1846
|
// Older hosts can hand over snapshots without storage/limits; fall back
|
|
1132
1847
|
// instead of throwing mid-render.
|
|
1133
1848
|
const storage = view.storage || {}
|
|
1134
|
-
const limits = view.limits || {
|
|
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
|
|
1135
1856
|
const injection = view.injection || { registered: false, chars: 0, error: null }
|
|
1136
1857
|
// List order is display-only and never changes what the Host injects
|
|
1137
1858
|
// (the injection order is decided host-side). The default mirrors the
|
|
@@ -1152,6 +1873,15 @@ window.__ModuleLoader__.load({
|
|
|
1152
1873
|
}
|
|
1153
1874
|
const entries = sortEntries(data.entries)
|
|
1154
1875
|
const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
|
|
1876
|
+
// Persona profiles, in stored order (stable injection order). Old hosts
|
|
1877
|
+
// simply have no array.
|
|
1878
|
+
const profiles = Array.isArray(data.profiles) ? data.profiles : []
|
|
1879
|
+
const activeProfiles = profiles.filter((p) => p.enabled === true && p.content !== '').length
|
|
1880
|
+
// Prefer the Host's rendered size (includes the shared header); fall
|
|
1881
|
+
// back to a local sum for hosts that do not publish the field yet.
|
|
1882
|
+
const profileChars = injection && typeof injection === 'object' && typeof injection.profileChars === 'number'
|
|
1883
|
+
? injection.profileChars
|
|
1884
|
+
: profiles.reduce((n, p) => n + (p.enabled === true && p.content ? p.name.length + p.content.length + 5 : 0), 0)
|
|
1155
1885
|
// Every tag actually in use, most frequent first (then name), for the
|
|
1156
1886
|
// tag filter row.
|
|
1157
1887
|
const allTags = (() => {
|
|
@@ -1169,6 +1899,39 @@ window.__ModuleLoader__.load({
|
|
|
1169
1899
|
// Older hosts simply publish none of these fields: the meter falls back
|
|
1170
1900
|
// to data.budgetChars and the maps stay empty.
|
|
1171
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]))
|
|
1172
1935
|
const omittedById = new Map()
|
|
1173
1936
|
const truncatedById = new Map()
|
|
1174
1937
|
{
|
|
@@ -1271,6 +2034,127 @@ window.__ModuleLoader__.load({
|
|
|
1271
2034
|
? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
|
|
1272
2035
|
: null)
|
|
1273
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,
|
|
2101
|
+
// ---- User persona ("用户形象"): facts about the USER, not a role ----
|
|
2102
|
+
h('div', { key: 'profiles', className: 'im-profiles' },
|
|
2103
|
+
h('div', { className: 'im-profiles-head' },
|
|
2104
|
+
h('span', { className: 'im-section-title' }, t('profiles.section')),
|
|
2105
|
+
h('button', {
|
|
2106
|
+
className: 'im-btn im-btn-primary',
|
|
2107
|
+
disabled: busy,
|
|
2108
|
+
onClick: () => { setProfileDraft(blankProfileDraft()); setDraft(null); setError('') },
|
|
2109
|
+
}, t('profiles.new')),
|
|
2110
|
+
h('span', { className: 'im-spacer' }),
|
|
2111
|
+
h('span', { className: 'im-meta' }, t('profiles.count', { total: profiles.length, active: activeProfiles }))),
|
|
2112
|
+
h('div', { className: 'im-meta im-profile-sub' }, t('profiles.subtitle')),
|
|
2113
|
+
profileDraft !== null
|
|
2114
|
+
? h(ProfileEditor, {
|
|
2115
|
+
draft: profileDraft,
|
|
2116
|
+
busy: busy,
|
|
2117
|
+
limits: view !== null && view.limits ? view.limits : null,
|
|
2118
|
+
onChange: setProfileDraft,
|
|
2119
|
+
onSave: saveProfileDraft,
|
|
2120
|
+
onCancel: () => setProfileDraft(null),
|
|
2121
|
+
})
|
|
2122
|
+
: null,
|
|
2123
|
+
profiles.length === 0 && profileDraft === null
|
|
2124
|
+
? h('div', { className: 'im-empty' }, t('profiles.empty'))
|
|
2125
|
+
: profiles.map((profile) => h('div', {
|
|
2126
|
+
className: 'im-profile-item' + (profile.enabled === true ? '' : ' im-profile-item-off'),
|
|
2127
|
+
key: profile.id,
|
|
2128
|
+
},
|
|
2129
|
+
h('div', { className: 'im-item-head' },
|
|
2130
|
+
h('span', { className: 'im-profile-name' },
|
|
2131
|
+
profile.name !== '' ? profile.name : t('profiles.untitled')),
|
|
2132
|
+
h('span', { className: 'im-spacer' }),
|
|
2133
|
+
h('label', { className: 'im-switch' },
|
|
2134
|
+
h('input', {
|
|
2135
|
+
type: 'checkbox',
|
|
2136
|
+
checked: profile.enabled === true,
|
|
2137
|
+
disabled: busy,
|
|
2138
|
+
onChange: () => toggleProfile(profile),
|
|
2139
|
+
}),
|
|
2140
|
+
t('profiles.use')),
|
|
2141
|
+
h('button', {
|
|
2142
|
+
className: 'im-btn',
|
|
2143
|
+
disabled: busy,
|
|
2144
|
+
onClick: () => editProfile(profile),
|
|
2145
|
+
}, t('entry.edit')),
|
|
2146
|
+
h('button', {
|
|
2147
|
+
className: 'im-btn im-btn-danger',
|
|
2148
|
+
disabled: busy,
|
|
2149
|
+
onClick: () => removeProfile(profile),
|
|
2150
|
+
}, t('entry.delete'))),
|
|
2151
|
+
h(EntryBody, { text: profile.content, key: 'body' }),
|
|
2152
|
+
profile.updatedAt > 0
|
|
2153
|
+
? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(profile.updatedAt).toLocaleString() }))
|
|
2154
|
+
: null)),
|
|
2155
|
+
activeProfiles > 0
|
|
2156
|
+
? h('div', { className: 'im-meta im-meter-line' }, t('profiles.chars', { count: profileChars }))
|
|
2157
|
+
: null),
|
|
1274
2158
|
// F3 bulk action bar, only while in multi-select mode. "Select all"
|
|
1275
2159
|
// covers the currently visible (filtered/searched) entries.
|
|
1276
2160
|
(batchMode && entries.length > 0)
|
|
@@ -1317,6 +2201,49 @@ window.__ModuleLoader__.load({
|
|
|
1317
2201
|
}, t('batch.delete')),
|
|
1318
2202
|
h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
|
|
1319
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,
|
|
1320
2247
|
// F2 filters: tag chips (only when tags exist) plus mode/status/sort.
|
|
1321
2248
|
entries.length > 0
|
|
1322
2249
|
? h('div', { key: 'filters', className: 'im-filter-row' },
|
|
@@ -1378,7 +2305,7 @@ window.__ModuleLoader__.load({
|
|
|
1378
2305
|
h('button', {
|
|
1379
2306
|
className: 'im-btn im-btn-primary',
|
|
1380
2307
|
disabled: busy,
|
|
1381
|
-
onClick: () => { setDraft(blankDraft()); setError('') },
|
|
2308
|
+
onClick: () => { setDraft(blankDraft()); setProfileDraft(null); setError('') },
|
|
1382
2309
|
}, t('action.new')),
|
|
1383
2310
|
h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, t('action.reload')),
|
|
1384
2311
|
h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, t('action.export')),
|
|
@@ -1390,6 +2317,20 @@ window.__ModuleLoader__.load({
|
|
|
1390
2317
|
if (el !== null && el !== undefined) el.click()
|
|
1391
2318
|
},
|
|
1392
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')),
|
|
1393
2334
|
h('button', {
|
|
1394
2335
|
className: 'im-btn',
|
|
1395
2336
|
disabled: busy,
|
|
@@ -1411,6 +2352,64 @@ window.__ModuleLoader__.load({
|
|
|
1411
2352
|
? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
|
|
1412
2353
|
: null,
|
|
1413
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),
|
|
1414
2413
|
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
1415
2414
|
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
1416
2415
|
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
|
|
@@ -1445,8 +2444,73 @@ window.__ModuleLoader__.load({
|
|
|
1445
2444
|
ev.target.value = ''
|
|
1446
2445
|
if (file !== null) void handleImportFile(file)
|
|
1447
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
|
+
},
|
|
1448
2461
|
}))
|
|
1449
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
|
+
|
|
1450
2514
|
const items = visible.map((entry) => {
|
|
1451
2515
|
const tags = [
|
|
1452
2516
|
h('span', {
|
|
@@ -1467,6 +2531,16 @@ window.__ModuleLoader__.load({
|
|
|
1467
2531
|
className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
|
|
1468
2532
|
}, t('tag.truncated')))
|
|
1469
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
|
+
}
|
|
1470
2544
|
return h('div', {
|
|
1471
2545
|
className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
|
|
1472
2546
|
key: entry.id,
|
|
@@ -1503,7 +2577,7 @@ window.__ModuleLoader__.load({
|
|
|
1503
2577
|
: h('button', {
|
|
1504
2578
|
className: 'im-btn',
|
|
1505
2579
|
disabled: busy,
|
|
1506
|
-
onClick: () => { setDraft(cloneEntry(entry)); setError('') },
|
|
2580
|
+
onClick: () => { setDraft(cloneEntry(entry)); setProfileDraft(null); setError('') },
|
|
1507
2581
|
}, t('entry.edit')),
|
|
1508
2582
|
batchMode
|
|
1509
2583
|
? null
|
|
@@ -1640,6 +2714,9 @@ window.__ModuleLoader__.load({
|
|
|
1640
2714
|
exports.__nextBudgetText = nextBudgetText
|
|
1641
2715
|
exports.__budgetMeter = budgetMeter
|
|
1642
2716
|
exports.__parseTagInput = parseTagInput
|
|
2717
|
+
exports.__relevanceTerms = relevanceTerms
|
|
2718
|
+
exports.__relevancePreview = relevancePreview
|
|
2719
|
+
exports.__sourceParseVerdict = sourceParseVerdict
|
|
1643
2720
|
exports.__strings = STRINGS
|
|
1644
2721
|
exports.__ns = NS
|
|
1645
2722
|
exports.__dictionaryT = dictionaryT
|