dsh-instruction-memory 1.0.17 → 1.0.18
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 +28 -0
- package/README.md +12 -1
- package/lib/client.js +453 -45
- package/lib/index.js +165 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.0.18] — 2026-10-13
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **标签分组(F2)**:每条指令可维护若干标签(编辑器内回车/逗号/分号提交的 chips,最多 8 个、
|
|
15
|
+
单个不超过 24 字符;去空白、折叠连续空白、去重)。标签只服务于设置页,**不会进入注入文本**,
|
|
16
|
+
`buildBlock`/`planInjection` 的输出与决策不受其影响。条目新增可选 `tags: string[]`(格式仍为 v1,
|
|
17
|
+
旧记忆无该字段即按无标签加载)。
|
|
18
|
+
- **多维筛选与排序(F2)**:列表可按标签(chips)、生效方式(始终/按需)、启用状态组合筛选,
|
|
19
|
+
并在按优先级 / 按更新时间 / 按标题之间切换排序;关键词搜索与这些筛选叠加且覆盖标签文本。
|
|
20
|
+
排序仅影响列表展示,宿主侧注入顺序不变。筛选无结果时给出独立空态文案。
|
|
21
|
+
- **批量操作(F3)**:设置页新增多选模式,可一次性启用、停用、设置优先级、加标签或删除;
|
|
22
|
+
全选只作用于当前筛选/搜索后的可见条目,批量删除需确认。Host 新增 `batch` 路由
|
|
23
|
+
(`set-enabled` / `set-priority` / `add-tags` / `remove-tags` / `delete` 五个 op):
|
|
24
|
+
整批在**单次事务**内完成(一次落盘、rev 只 +1、一次 SSE 推送),先纯计算变更计划,
|
|
25
|
+
全部条目已是目标状态或只涉及不存在 id 时不写盘,部分命中会在消息中注明未找到的条数;
|
|
26
|
+
复用未来版本拒绝、rev 冲突、写盘失败回滚等既有保护。批量元数据调整不重盖 `updatedAt`。
|
|
27
|
+
- 请求体上限推导纳入标签字段,继续保证「本插件导出的文件必能导回」。
|
|
28
|
+
|
|
29
|
+
### 测试
|
|
30
|
+
|
|
31
|
+
- `smoke-test.mjs` 新增标签规范化与「标签不进注入文本/计划」断言。
|
|
32
|
+
- `contract-test.mjs` 新增独立 `batch` 验证块:经真实路由断言五个 op、单次事务(rev 恰好 +1)、
|
|
33
|
+
no-op 不写盘、部分命中计数、非法 op/value/空 ids 拒绝、stale rev 冲突、标签不进 preview。
|
|
34
|
+
- `verify.mjs` 新增客户端 `__parseTagInput` 断言(分隔符、空白折叠、去重、上限、非字符串输入)
|
|
35
|
+
与 F2/F3 双语文案键存在性;`draftIsDirty` 对 `tags` 缺省/`[]` 归一比较,旧条目不被误判为已修改。
|
|
36
|
+
- `docs/demo.html` 演示数据加入标签,演示宿主支持 `batch` 路由与标签编辑/筛选/排序/批量操作。
|
|
37
|
+
|
|
10
38
|
## [1.0.17] — 2026-10-12
|
|
11
39
|
|
|
12
40
|
### 新增
|
package/README.md
CHANGED
|
@@ -59,6 +59,14 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
59
59
|
标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。
|
|
60
60
|
调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的**展示**,不改变注入文本本身;
|
|
61
61
|
存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
|
|
62
|
+
- **标签、筛选与排序**:每条指令可加若干**标签**(回车或逗号添加,最多 8 个、单个不超过 24 字符)。
|
|
63
|
+
标签只用于设置页分组与筛选,**不会进入注入文本**;标签缺失的旧记忆照常加载(等同无标签)。
|
|
64
|
+
列表可按标签、生效方式(始终/按需)、启用状态组合筛选,并在「按优先级 / 按更新时间 / 按标题」
|
|
65
|
+
之间切换排序;列表排序纯为查看方便,不改变宿主侧的注入顺序。
|
|
66
|
+
- **批量操作**:点「批量操作」进入多选,可对勾选条目一次性启用、停用、设置优先级、加标签或删除;
|
|
67
|
+
多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是**单次写入**
|
|
68
|
+
(只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。
|
|
69
|
+
批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。
|
|
62
70
|
|
|
63
71
|
条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
|
|
64
72
|
|
|
@@ -437,6 +445,7 @@ Package → Settings → Trusted publishing → Add trusted publisher
|
|
|
437
445
|
"when": "",
|
|
438
446
|
"priority": 1,
|
|
439
447
|
"enabled": true,
|
|
448
|
+
"tags": ["沟通"],
|
|
440
449
|
"updatedAt": 1700000000000
|
|
441
450
|
}
|
|
442
451
|
]
|
|
@@ -444,4 +453,6 @@ Package → Settings → Trusted publishing → Add trusted publisher
|
|
|
444
453
|
```
|
|
445
454
|
|
|
446
455
|
`mode` 为 `always` | `auto`;`priority` 为 `2` 高 / `1` 普通 / `0` 低。`rev` 是存储修订号,
|
|
447
|
-
由插件在每次成功写入时递增,**无需手填**(缺失视为 0
|
|
456
|
+
由插件在每次成功写入时递增,**无需手填**(缺失视为 0)。`tags` 为可选的短标签字符串数组
|
|
457
|
+
(最多 8 个、单个不超过 24 字符,仅用于设置页筛选,不参与注入);缺失或非数组一律按无标签处理,
|
|
458
|
+
非法项会被剔除、超长会被截断、重复项会去重,格式版本号仍为 `1`。
|
package/lib/client.js
CHANGED
|
@@ -64,6 +64,25 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
|
|
65
65
|
.im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
66
66
|
.im-meter-fill-warn { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
67
|
+
.im-entry-tags { display:flex; flex-wrap:wrap; gap:4px; }
|
|
68
|
+
.im-chip { display:inline-flex; align-items:center; gap:3px; padding:1px 4px 1px 9px; border-radius:999px; font-size:11px;
|
|
69
|
+
border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14)); color:var(--dsw-alias-label-secondary,#6b7280);
|
|
70
|
+
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03)); white-space:nowrap; }
|
|
71
|
+
.im-chip-x { border:none; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1;
|
|
72
|
+
padding:0 5px; border-radius:999px; }
|
|
73
|
+
.im-chip-x:hover:not(:disabled) { background:rgba(0,0,0,.08); }
|
|
74
|
+
.im-filter-row { display:flex; flex-direction:column; gap:7px; }
|
|
75
|
+
.im-filter-chips { display:flex; flex-wrap:wrap; gap:6px; }
|
|
76
|
+
.im-filter-chip { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
|
|
77
|
+
color:inherit; border-radius:999px; padding:2px 11px; font-size:11.5px; font-family:inherit; cursor:pointer; }
|
|
78
|
+
.im-filter-chip-on { background:var(--dsw-alias-brand-primary,#3b6cff); border-color:transparent; color:#fff; }
|
|
79
|
+
.im-filter-selects { display:flex; flex-wrap:wrap; gap:12px; }
|
|
80
|
+
.im-fselect { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
81
|
+
.im-select-sm { padding:3px 6px; font-size:12px; border-radius:7px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18));
|
|
82
|
+
background:var(--dsw-alias-bg-base,#fff); color:inherit; }
|
|
83
|
+
.im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
|
|
84
|
+
border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
|
|
85
|
+
.im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
67
86
|
.im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
|
|
68
87
|
border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
69
88
|
.im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
|
|
@@ -157,6 +176,37 @@ window.__ModuleLoader__.load({
|
|
|
157
176
|
'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
|
|
158
177
|
'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
|
|
159
178
|
'search.matchCount': '匹配 {shown} / {total} 条',
|
|
179
|
+
'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
|
|
180
|
+
'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
|
|
181
|
+
'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
|
|
182
|
+
'tags.duplicate': '已有同名标签',
|
|
183
|
+
'tags.full': '每条最多 {max} 个标签',
|
|
184
|
+
'filter.all': '全部',
|
|
185
|
+
'filter.tag': '标签',
|
|
186
|
+
'filter.mode': '生效方式',
|
|
187
|
+
'filter.status': '状态',
|
|
188
|
+
'filter.modeAlways': '始终',
|
|
189
|
+
'filter.modeAuto': '按需',
|
|
190
|
+
'filter.statusEnabled': '已启用',
|
|
191
|
+
'filter.statusDisabled': '已停用',
|
|
192
|
+
'sort.label': '排序',
|
|
193
|
+
'sort.priority': '按优先级',
|
|
194
|
+
'sort.updated': '按更新时间',
|
|
195
|
+
'sort.title': '按标题',
|
|
196
|
+
'action.batch': '批量操作',
|
|
197
|
+
'batch.exit': '退出批量',
|
|
198
|
+
'batch.selectAll': '全选',
|
|
199
|
+
'batch.selected': '已选 {count} 条',
|
|
200
|
+
'batch.enable': '启用',
|
|
201
|
+
'batch.disable': '停用',
|
|
202
|
+
'batch.priority': '设优先级…',
|
|
203
|
+
'batch.addTags': '加标签…',
|
|
204
|
+
'batch.delete': '删除',
|
|
205
|
+
'batch.confirmDelete': '确定删除选中的 {count} 条指令?删除后无法恢复。',
|
|
206
|
+
'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
|
|
207
|
+
'batch.tagEmpty': '未输入有效的标签',
|
|
208
|
+
'batch.noneSelected': '请先勾选要操作的指令',
|
|
209
|
+
'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
|
|
160
210
|
'action.new': '+ 新增指令',
|
|
161
211
|
'action.reload': '重新载入',
|
|
162
212
|
'action.export': '导出',
|
|
@@ -173,6 +223,7 @@ window.__ModuleLoader__.load({
|
|
|
173
223
|
'preview.empty': '(当前没有生效中的指令)',
|
|
174
224
|
'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
|
|
175
225
|
'list.noMatch': '没有匹配“{query}”的指令。',
|
|
226
|
+
'list.filteredEmpty': '没有符合当前筛选条件的指令。',
|
|
176
227
|
'tag.always': '始终',
|
|
177
228
|
'tag.auto': '按需',
|
|
178
229
|
'entry.enabled': '启用',
|
|
@@ -240,6 +291,37 @@ window.__ModuleLoader__.load({
|
|
|
240
291
|
'rules.note': '“Always” applies unconditionally; “On demand” lets the model judge by relevance. When the limit is exceeded, higher-priority entries are kept and the rest are skipped.',
|
|
241
292
|
'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
|
|
242
293
|
'search.matchCount': '{shown} of {total} match',
|
|
294
|
+
'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
|
|
295
|
+
'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
|
|
296
|
+
'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
|
|
297
|
+
'tags.duplicate': 'This tag already exists',
|
|
298
|
+
'tags.full': 'Up to {max} tags per entry',
|
|
299
|
+
'filter.all': 'All',
|
|
300
|
+
'filter.tag': 'Tag',
|
|
301
|
+
'filter.mode': 'Mode',
|
|
302
|
+
'filter.status': 'Status',
|
|
303
|
+
'filter.modeAlways': 'Always',
|
|
304
|
+
'filter.modeAuto': 'On demand',
|
|
305
|
+
'filter.statusEnabled': 'Enabled',
|
|
306
|
+
'filter.statusDisabled': 'Disabled',
|
|
307
|
+
'sort.label': 'Sort',
|
|
308
|
+
'sort.priority': 'By priority',
|
|
309
|
+
'sort.updated': 'By updated time',
|
|
310
|
+
'sort.title': 'By title',
|
|
311
|
+
'action.batch': 'Bulk actions',
|
|
312
|
+
'batch.exit': 'Exit bulk',
|
|
313
|
+
'batch.selectAll': 'Select all',
|
|
314
|
+
'batch.selected': '{count} selected',
|
|
315
|
+
'batch.enable': 'Enable',
|
|
316
|
+
'batch.disable': 'Disable',
|
|
317
|
+
'batch.priority': 'Set priority…',
|
|
318
|
+
'batch.addTags': 'Add tags…',
|
|
319
|
+
'batch.delete': 'Delete',
|
|
320
|
+
'batch.confirmDelete': 'Delete the {count} selected entries? This cannot be undone.',
|
|
321
|
+
'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
|
|
322
|
+
'batch.tagEmpty': 'No valid tag entered',
|
|
323
|
+
'batch.noneSelected': 'Tick the entries to act on first',
|
|
324
|
+
'batch.promptUnavailable': 'Quick entry is unavailable in this environment; edit tags one by one',
|
|
243
325
|
'action.new': '+ New instruction',
|
|
244
326
|
'action.reload': 'Reload',
|
|
245
327
|
'action.export': 'Export',
|
|
@@ -256,6 +338,7 @@ window.__ModuleLoader__.load({
|
|
|
256
338
|
'preview.empty': '(no active instructions right now)',
|
|
257
339
|
'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
|
|
258
340
|
'list.noMatch': 'No instructions match “{query}”.',
|
|
341
|
+
'list.filteredEmpty': 'No instructions match the current filters.',
|
|
259
342
|
'tag.always': 'Always',
|
|
260
343
|
'tag.auto': 'On demand',
|
|
261
344
|
'entry.enabled': 'Enabled',
|
|
@@ -396,6 +479,32 @@ window.__ModuleLoader__.load({
|
|
|
396
479
|
|
|
397
480
|
/* ---------------- data helpers ---------------- */
|
|
398
481
|
|
|
482
|
+
// Mirror the Host's tag limits (lib/index.js); kept local so the editor
|
|
483
|
+
// constrains input before the round-trip and tests can pin both to match.
|
|
484
|
+
const MAX_TAGS = 8
|
|
485
|
+
const MAX_TAG_LEN = 24
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Parse free text typed into the tag box into normalised tag candidates.
|
|
489
|
+
* Tags are split on commas (ASCII/full-width), semicolons and newlines —
|
|
490
|
+
* never spaces, so a tag may contain spaces. Each piece is trimmed,
|
|
491
|
+
* collapsed to one line, cut to MAX_TAG_LEN, de-duplicated and capped at
|
|
492
|
+
* MAX_TAGS. Pinned as a pure export.
|
|
493
|
+
*/
|
|
494
|
+
function parseTagInput(text) {
|
|
495
|
+
if (typeof text !== 'string') return []
|
|
496
|
+
const out = []
|
|
497
|
+
const seen = new Set()
|
|
498
|
+
for (const piece of text.split(/[,,;;\n\r]+/)) {
|
|
499
|
+
const tag = piece.replace(/\s+/g, ' ').trim().slice(0, MAX_TAG_LEN)
|
|
500
|
+
if (tag === '' || seen.has(tag)) continue
|
|
501
|
+
seen.add(tag)
|
|
502
|
+
if (out.length >= MAX_TAGS) break
|
|
503
|
+
out.push(tag)
|
|
504
|
+
}
|
|
505
|
+
return out
|
|
506
|
+
}
|
|
507
|
+
|
|
399
508
|
function cloneEntry(entry) {
|
|
400
509
|
return {
|
|
401
510
|
id: entry.id,
|
|
@@ -405,12 +514,13 @@ window.__ModuleLoader__.load({
|
|
|
405
514
|
when: entry.when,
|
|
406
515
|
priority: entry.priority,
|
|
407
516
|
enabled: entry.enabled,
|
|
517
|
+
tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
|
|
408
518
|
updatedAt: entry.updatedAt,
|
|
409
519
|
}
|
|
410
520
|
}
|
|
411
521
|
|
|
412
522
|
function blankDraft() {
|
|
413
|
-
return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, updatedAt: 0 }
|
|
523
|
+
return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
|
|
414
524
|
}
|
|
415
525
|
|
|
416
526
|
/**
|
|
@@ -428,7 +538,11 @@ window.__ModuleLoader__.load({
|
|
|
428
538
|
const fields = ['title', 'content', 'mode', 'when', 'priority', 'enabled']
|
|
429
539
|
const base = draft.id === '' ? blankDraft() : (entries || []).find((entry) => entry.id === draft.id)
|
|
430
540
|
if (base === undefined) return true
|
|
431
|
-
|
|
541
|
+
if (fields.some((field) => String(draft[field]) !== String(base[field]))) return true
|
|
542
|
+
// Tags are an optional array that may be absent on old entries; compare
|
|
543
|
+
// a normalised form so [] and undefined both read as "no tags".
|
|
544
|
+
const tagsKey = (entry) => (Array.isArray(entry && entry.tags) ? entry.tags : []).join('')
|
|
545
|
+
return tagsKey(draft) !== tagsKey(base)
|
|
432
546
|
}
|
|
433
547
|
|
|
434
548
|
/**
|
|
@@ -642,6 +756,13 @@ window.__ModuleLoader__.load({
|
|
|
642
756
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
643
757
|
})))
|
|
644
758
|
}
|
|
759
|
+
children.push(h('div', { key: 'tags' },
|
|
760
|
+
h('label', { className: 'im-label' }, t('tags.label')),
|
|
761
|
+
h(TagInput, {
|
|
762
|
+
tags: draft.tags,
|
|
763
|
+
disabled: props.busy,
|
|
764
|
+
onChange: (next) => set({ tags: next }),
|
|
765
|
+
})))
|
|
645
766
|
children.push(h('div', { className: 'im-row-wrap', key: 'a' },
|
|
646
767
|
h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
|
|
647
768
|
props.busy ? t('editor.saving') : t('editor.save')),
|
|
@@ -651,6 +772,73 @@ window.__ModuleLoader__.load({
|
|
|
651
772
|
return h('div', { className: 'im-card' }, children)
|
|
652
773
|
}
|
|
653
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Tag chips editor for one entry. Tags live only on this panel (they are
|
|
777
|
+
* never injected). Committing (Enter / comma / semicolon / blur) parses the
|
|
778
|
+
* typed text through parseTagInput and merges de-duplicated tags up to the
|
|
779
|
+
* shared cap; Backspace on an empty box removes the last chip.
|
|
780
|
+
*/
|
|
781
|
+
function TagInput(props) {
|
|
782
|
+
const [text, setText] = React.useState('')
|
|
783
|
+
const tags = Array.isArray(props.tags) ? props.tags : []
|
|
784
|
+
const merge = (pieces) => {
|
|
785
|
+
const merged = []
|
|
786
|
+
for (const tag of [...tags, ...pieces]) {
|
|
787
|
+
if (!merged.includes(tag)) merged.push(tag)
|
|
788
|
+
}
|
|
789
|
+
props.onChange(merged.slice(0, MAX_TAGS))
|
|
790
|
+
}
|
|
791
|
+
const commit = () => {
|
|
792
|
+
const parsed = parseTagInput(text)
|
|
793
|
+
if (parsed.length > 0) merge(parsed)
|
|
794
|
+
setText('')
|
|
795
|
+
}
|
|
796
|
+
const remove = (tag) => {
|
|
797
|
+
props.onChange(tags.filter((candidate) => candidate !== tag))
|
|
798
|
+
}
|
|
799
|
+
return h('div', null,
|
|
800
|
+
tags.length > 0
|
|
801
|
+
? h('div', { className: 'im-entry-tags' },
|
|
802
|
+
tags.map((tag) => h('span', { className: 'im-chip', key: tag },
|
|
803
|
+
tag,
|
|
804
|
+
h('button', {
|
|
805
|
+
type: 'button', className: 'im-chip-x', disabled: props.disabled,
|
|
806
|
+
onClick: () => remove(tag),
|
|
807
|
+
}, '×'))))
|
|
808
|
+
: null,
|
|
809
|
+
h('input', {
|
|
810
|
+
className: 'im-input',
|
|
811
|
+
value: text,
|
|
812
|
+
disabled: props.disabled,
|
|
813
|
+
maxLength: MAX_TAG_LEN,
|
|
814
|
+
placeholder: t('tags.placeholder'),
|
|
815
|
+
onChange: (ev) => {
|
|
816
|
+
const v = ev.target.value
|
|
817
|
+
if (/[,,;;\n\r]/.test(v)) {
|
|
818
|
+
// A separator commits the pending text as chips immediately.
|
|
819
|
+
// v is the input's full new value (it already contains what was
|
|
820
|
+
// typed before the separator) — do NOT prepend `text`, that
|
|
821
|
+
// duplicated it ("b" + "b," -> "bb").
|
|
822
|
+
const parsed = parseTagInput(v)
|
|
823
|
+
if (parsed.length > 0) merge(parsed)
|
|
824
|
+
setText('')
|
|
825
|
+
} else {
|
|
826
|
+
setText(v.slice(0, MAX_TAG_LEN))
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
onKeyDown: (ev) => {
|
|
830
|
+
if (ev.key === 'Enter') {
|
|
831
|
+
ev.preventDefault()
|
|
832
|
+
commit()
|
|
833
|
+
} else if (ev.key === 'Backspace' && text === '' && tags.length > 0) {
|
|
834
|
+
remove(tags[tags.length - 1])
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
onBlur: commit,
|
|
838
|
+
}),
|
|
839
|
+
h('div', { className: 'im-meta' }, t('tags.hint', { max: MAX_TAGS, len: MAX_TAG_LEN })))
|
|
840
|
+
}
|
|
841
|
+
|
|
654
842
|
/**
|
|
655
843
|
* Entry body with an explicit 展开/收起 toggle. The collapsed cap lives in
|
|
656
844
|
* CSS (max-height + overflow:hidden), so whether overflow actually happens
|
|
@@ -686,6 +874,14 @@ window.__ModuleLoader__.load({
|
|
|
686
874
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
687
875
|
const [query, setQuery] = React.useState('')
|
|
688
876
|
const [undo, setUndo] = React.useState(null)
|
|
877
|
+
// F2: tag/mode/status filters ('' = all) and the list sort key.
|
|
878
|
+
const [tagFilter, setTagFilter] = React.useState('')
|
|
879
|
+
const [modeFilter, setModeFilter] = React.useState('')
|
|
880
|
+
const [statusFilter, setStatusFilter] = React.useState('')
|
|
881
|
+
const [sortKey, setSortKey] = React.useState('priority')
|
|
882
|
+
// F3: bulk-select mode and the chosen entry ids in it.
|
|
883
|
+
const [batchMode, setBatchMode] = React.useState(false)
|
|
884
|
+
const [selected, setSelected] = React.useState([])
|
|
689
885
|
const importInputRef = React.useRef(null)
|
|
690
886
|
// Whether the budget input is being edited right now. A live SSE push
|
|
691
887
|
// from another window must not replace the number under the cursor;
|
|
@@ -770,6 +966,54 @@ window.__ModuleLoader__.load({
|
|
|
770
966
|
}
|
|
771
967
|
}
|
|
772
968
|
|
|
969
|
+
// ---- F3 bulk actions: one request, one host-side transaction ----
|
|
970
|
+
|
|
971
|
+
/** Selected ids that still exist in the latest snapshot. */
|
|
972
|
+
const liveSelected = () => selected.filter((id) => data.entries.some((entry) => entry.id === id))
|
|
973
|
+
|
|
974
|
+
function toggleSelected(id) {
|
|
975
|
+
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function exitBatch() {
|
|
979
|
+
setBatchMode(false)
|
|
980
|
+
setSelected([])
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function doBatch(op, value) {
|
|
984
|
+
const ids = liveSelected()
|
|
985
|
+
if (ids.length === 0) {
|
|
986
|
+
setError(t('batch.noneSelected'))
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
if (op === 'delete'
|
|
990
|
+
&& typeof window.confirm === 'function'
|
|
991
|
+
&& !window.confirm(t('batch.confirmDelete', { count: ids.length }))) {
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
const result = await send('batch', { op, ids, value, baseRev: revOf() })
|
|
995
|
+
if (result !== null && result.ok === true && op === 'delete') {
|
|
996
|
+
// Drop deleted ids from the selection; other ops keep it so several
|
|
997
|
+
// bulk actions can be chained (e.g. set priority, then disable).
|
|
998
|
+
setSelected((prev) => prev.filter((id) => !ids.includes(id)))
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function batchAddTags() {
|
|
1003
|
+
if (typeof window.prompt !== 'function') {
|
|
1004
|
+
setError(t('batch.promptUnavailable'))
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
const answer = window.prompt(t('batch.tagPrompt', { count: liveSelected().length }), '')
|
|
1008
|
+
if (answer === null) return
|
|
1009
|
+
const tags = parseTagInput(answer)
|
|
1010
|
+
if (tags.length === 0) {
|
|
1011
|
+
setError(t('batch.tagEmpty'))
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
void doBatch('add-tags', tags)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
773
1017
|
async function doExport() {
|
|
774
1018
|
try {
|
|
775
1019
|
const result = await callHost('export-data', null)
|
|
@@ -889,8 +1133,38 @@ window.__ModuleLoader__.load({
|
|
|
889
1133
|
const storage = view.storage || {}
|
|
890
1134
|
const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
|
|
891
1135
|
const injection = view.injection || { registered: false, chars: 0, error: null }
|
|
892
|
-
|
|
893
|
-
|
|
1136
|
+
// List order is display-only and never changes what the Host injects
|
|
1137
|
+
// (the injection order is decided host-side). The default mirrors the
|
|
1138
|
+
// previous fixed order; updated-time and title are the other sorts.
|
|
1139
|
+
const sortEntries = (list) => {
|
|
1140
|
+
const copy = list.slice()
|
|
1141
|
+
if (sortKey === 'updated') {
|
|
1142
|
+
copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
|
|
1143
|
+
} else if (sortKey === 'title') {
|
|
1144
|
+
copy.sort((a, b) =>
|
|
1145
|
+
a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
|
1146
|
+
|| (b.priority - a.priority)
|
|
1147
|
+
|| (b.updatedAt - a.updatedAt))
|
|
1148
|
+
} else {
|
|
1149
|
+
copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
|
|
1150
|
+
}
|
|
1151
|
+
return copy
|
|
1152
|
+
}
|
|
1153
|
+
const entries = sortEntries(data.entries)
|
|
1154
|
+
const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
|
|
1155
|
+
// Every tag actually in use, most frequent first (then name), for the
|
|
1156
|
+
// tag filter row.
|
|
1157
|
+
const allTags = (() => {
|
|
1158
|
+
const counts = new Map()
|
|
1159
|
+
for (const entry of data.entries) {
|
|
1160
|
+
for (const tag of Array.isArray(entry.tags) ? entry.tags : []) {
|
|
1161
|
+
if (typeof tag === 'string' && tag !== '') counts.set(tag, (counts.get(tag) || 0) + 1)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return [...counts.entries()]
|
|
1165
|
+
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
|
|
1166
|
+
.map(([tag]) => tag)
|
|
1167
|
+
})()
|
|
894
1168
|
// Budget usage + per-entry marks straight from the Host's injection plan.
|
|
895
1169
|
// Older hosts simply publish none of these fields: the meter falls back
|
|
896
1170
|
// to data.budgetChars and the maps stay empty.
|
|
@@ -909,12 +1183,23 @@ window.__ModuleLoader__.load({
|
|
|
909
1183
|
}
|
|
910
1184
|
}
|
|
911
1185
|
const keyword = query.trim().toLowerCase()
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1186
|
+
const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
|
|
1187
|
+
const matchesFilters = (entry) => {
|
|
1188
|
+
if (tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(tagFilter))) return false
|
|
1189
|
+
if (modeFilter !== '' && entry.mode !== modeFilter) return false
|
|
1190
|
+
if (statusFilter === 'enabled' && entry.enabled !== true) return false
|
|
1191
|
+
if (statusFilter === 'disabled' && entry.enabled !== false) return false
|
|
1192
|
+
return true
|
|
1193
|
+
}
|
|
1194
|
+
const visible = entries.filter((entry) => {
|
|
1195
|
+
if (!matchesFilters(entry)) return false
|
|
1196
|
+
if (keyword === '') return true
|
|
1197
|
+
const inTags = (Array.isArray(entry.tags) ? entry.tags.join(' ') : '').toLowerCase()
|
|
1198
|
+
return entry.title.toLowerCase().indexOf(keyword) !== -1
|
|
916
1199
|
|| entry.content.toLowerCase().indexOf(keyword) !== -1
|
|
917
|
-
|| entry.when.toLowerCase().indexOf(keyword) !== -1
|
|
1200
|
+
|| entry.when.toLowerCase().indexOf(keyword) !== -1
|
|
1201
|
+
|| inTags.indexOf(keyword) !== -1
|
|
1202
|
+
})
|
|
918
1203
|
|
|
919
1204
|
const dotClass = injection.error
|
|
920
1205
|
? 'im-dot im-dot-err'
|
|
@@ -986,6 +1271,99 @@ window.__ModuleLoader__.load({
|
|
|
986
1271
|
? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
|
|
987
1272
|
: null)
|
|
988
1273
|
: null,
|
|
1274
|
+
// F3 bulk action bar, only while in multi-select mode. "Select all"
|
|
1275
|
+
// covers the currently visible (filtered/searched) entries.
|
|
1276
|
+
(batchMode && entries.length > 0)
|
|
1277
|
+
? h('div', { key: 'batchbar', className: 'im-batch-bar' },
|
|
1278
|
+
h('label', { className: 'im-switch' },
|
|
1279
|
+
h('input', {
|
|
1280
|
+
type: 'checkbox',
|
|
1281
|
+
className: 'im-batch-check',
|
|
1282
|
+
checked: visible.length > 0 && visible.every((entry) => selected.includes(entry.id)),
|
|
1283
|
+
disabled: busy,
|
|
1284
|
+
onChange: (ev) => {
|
|
1285
|
+
const visibleIds = visible.map((entry) => entry.id)
|
|
1286
|
+
if (ev.target.checked) {
|
|
1287
|
+
setSelected([...new Set([...selected, ...visibleIds])])
|
|
1288
|
+
} else {
|
|
1289
|
+
setSelected(selected.filter((id) => !visibleIds.includes(id)))
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
}),
|
|
1293
|
+
t('batch.selectAll')),
|
|
1294
|
+
h('span', { className: 'im-meta' }, t('batch.selected', { count: liveSelected().length })),
|
|
1295
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', true) }, t('batch.enable')),
|
|
1296
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', false) }, t('batch.disable')),
|
|
1297
|
+
h('select', {
|
|
1298
|
+
className: 'im-select im-select-sm',
|
|
1299
|
+
defaultValue: '',
|
|
1300
|
+
disabled: busy,
|
|
1301
|
+
onChange: (ev) => {
|
|
1302
|
+
const v = Number(ev.target.value)
|
|
1303
|
+
if (v === 0 || v === 1 || v === 2) void doBatch('set-priority', v)
|
|
1304
|
+
ev.target.value = ''
|
|
1305
|
+
},
|
|
1306
|
+
},
|
|
1307
|
+
h('option', { value: '' }, t('batch.priority')),
|
|
1308
|
+
h('option', { value: '2' }, t('priority.high')),
|
|
1309
|
+
h('option', { value: '1' }, t('priority.normal')),
|
|
1310
|
+
h('option', { value: '0' }, t('priority.low'))),
|
|
1311
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: batchAddTags }, t('batch.addTags')),
|
|
1312
|
+
h('span', { className: 'im-spacer' }),
|
|
1313
|
+
h('button', {
|
|
1314
|
+
className: 'im-btn im-btn-danger',
|
|
1315
|
+
disabled: busy,
|
|
1316
|
+
onClick: () => doBatch('delete', undefined),
|
|
1317
|
+
}, t('batch.delete')),
|
|
1318
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
|
|
1319
|
+
: null,
|
|
1320
|
+
// F2 filters: tag chips (only when tags exist) plus mode/status/sort.
|
|
1321
|
+
entries.length > 0
|
|
1322
|
+
? h('div', { key: 'filters', className: 'im-filter-row' },
|
|
1323
|
+
allTags.length > 0
|
|
1324
|
+
? h('div', { className: 'im-filter-chips' },
|
|
1325
|
+
h('button', {
|
|
1326
|
+
type: 'button',
|
|
1327
|
+
className: 'im-filter-chip' + (tagFilter === '' ? ' im-filter-chip-on' : ''),
|
|
1328
|
+
onClick: () => setTagFilter(''),
|
|
1329
|
+
}, t('filter.all')),
|
|
1330
|
+
allTags.map((tag) => h('button', {
|
|
1331
|
+
type: 'button',
|
|
1332
|
+
key: tag,
|
|
1333
|
+
className: 'im-filter-chip' + (tagFilter === tag ? ' im-filter-chip-on' : ''),
|
|
1334
|
+
title: t('filter.tag'),
|
|
1335
|
+
onClick: () => setTagFilter(tagFilter === tag ? '' : tag),
|
|
1336
|
+
}, tag)))
|
|
1337
|
+
: null,
|
|
1338
|
+
h('div', { className: 'im-filter-selects' },
|
|
1339
|
+
h('label', { className: 'im-fselect' }, t('filter.mode'),
|
|
1340
|
+
h('select', {
|
|
1341
|
+
className: 'im-select im-select-sm',
|
|
1342
|
+
value: modeFilter,
|
|
1343
|
+
onChange: (ev) => setModeFilter(ev.target.value),
|
|
1344
|
+
},
|
|
1345
|
+
h('option', { value: '' }, t('filter.all')),
|
|
1346
|
+
h('option', { value: 'always' }, t('filter.modeAlways')),
|
|
1347
|
+
h('option', { value: 'auto' }, t('filter.modeAuto')))),
|
|
1348
|
+
h('label', { className: 'im-fselect' }, t('filter.status'),
|
|
1349
|
+
h('select', {
|
|
1350
|
+
className: 'im-select im-select-sm',
|
|
1351
|
+
value: statusFilter,
|
|
1352
|
+
onChange: (ev) => setStatusFilter(ev.target.value),
|
|
1353
|
+
},
|
|
1354
|
+
h('option', { value: '' }, t('filter.all')),
|
|
1355
|
+
h('option', { value: 'enabled' }, t('filter.statusEnabled')),
|
|
1356
|
+
h('option', { value: 'disabled' }, t('filter.statusDisabled')))),
|
|
1357
|
+
h('label', { className: 'im-fselect' }, t('sort.label'),
|
|
1358
|
+
h('select', {
|
|
1359
|
+
className: 'im-select im-select-sm',
|
|
1360
|
+
value: sortKey,
|
|
1361
|
+
onChange: (ev) => setSortKey(ev.target.value),
|
|
1362
|
+
},
|
|
1363
|
+
h('option', { value: 'priority' }, t('sort.priority')),
|
|
1364
|
+
h('option', { value: 'updated' }, t('sort.updated')),
|
|
1365
|
+
h('option', { value: 'title' }, t('sort.title'))))))
|
|
1366
|
+
: null,
|
|
989
1367
|
entries.length > 0 ? h('div', { key: 'search' },
|
|
990
1368
|
h('input', {
|
|
991
1369
|
className: 'im-input',
|
|
@@ -993,7 +1371,9 @@ window.__ModuleLoader__.load({
|
|
|
993
1371
|
placeholder: t('search.placeholder', { count: entries.length }),
|
|
994
1372
|
onChange: (ev) => setQuery(ev.target.value),
|
|
995
1373
|
}),
|
|
996
|
-
query.trim() !== ''
|
|
1374
|
+
(query.trim() !== '' || filtersActive)
|
|
1375
|
+
? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length }))
|
|
1376
|
+
: null) : null,
|
|
997
1377
|
h('div', { className: 'im-row-wrap' },
|
|
998
1378
|
h('button', {
|
|
999
1379
|
className: 'im-btn im-btn-primary',
|
|
@@ -1015,6 +1395,11 @@ window.__ModuleLoader__.load({
|
|
|
1015
1395
|
disabled: busy,
|
|
1016
1396
|
onClick: () => setShowPreview(!showPreview),
|
|
1017
1397
|
}, showPreview ? t('action.hidePreview') : t('action.showPreview')),
|
|
1398
|
+
h('button', {
|
|
1399
|
+
className: 'im-btn',
|
|
1400
|
+
disabled: busy || entries.length === 0,
|
|
1401
|
+
onClick: () => { setBatchMode(!batchMode); setSelected([]) },
|
|
1402
|
+
}, batchMode ? t('batch.exit') : t('action.batch')),
|
|
1018
1403
|
h('span', { className: 'im-spacer' }),
|
|
1019
1404
|
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
1020
1405
|
h('div', { className: 'im-divider' }),
|
|
@@ -1087,46 +1472,65 @@ window.__ModuleLoader__.load({
|
|
|
1087
1472
|
key: entry.id,
|
|
1088
1473
|
},
|
|
1089
1474
|
h('div', { className: 'im-item-head' },
|
|
1475
|
+
batchMode
|
|
1476
|
+
? h('input', {
|
|
1477
|
+
type: 'checkbox',
|
|
1478
|
+
className: 'im-batch-check',
|
|
1479
|
+
checked: selected.includes(entry.id),
|
|
1480
|
+
disabled: busy,
|
|
1481
|
+
onChange: () => toggleSelected(entry.id),
|
|
1482
|
+
})
|
|
1483
|
+
: null,
|
|
1090
1484
|
h('span', { className: 'im-title' }, entry.title),
|
|
1091
1485
|
tags,
|
|
1092
1486
|
h('span', { className: 'im-spacer' }),
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1487
|
+
batchMode
|
|
1488
|
+
? null
|
|
1489
|
+
: h('label', { className: 'im-switch' },
|
|
1490
|
+
h('input', {
|
|
1491
|
+
type: 'checkbox',
|
|
1492
|
+
checked: entry.enabled === true,
|
|
1493
|
+
disabled: busy,
|
|
1494
|
+
onChange: () => {
|
|
1495
|
+
const next = cloneEntry(entry)
|
|
1496
|
+
next.enabled = !entry.enabled
|
|
1497
|
+
send('save-entry', { entry: next, baseRev: revOf() })
|
|
1498
|
+
},
|
|
1499
|
+
}),
|
|
1500
|
+
t('entry.enabled')),
|
|
1501
|
+
batchMode
|
|
1502
|
+
? null
|
|
1503
|
+
: h('button', {
|
|
1504
|
+
className: 'im-btn',
|
|
1505
|
+
disabled: busy,
|
|
1506
|
+
onClick: () => { setDraft(cloneEntry(entry)); setError('') },
|
|
1507
|
+
}, t('entry.edit')),
|
|
1508
|
+
batchMode
|
|
1509
|
+
? null
|
|
1510
|
+
: h('button', {
|
|
1511
|
+
className: 'im-btn im-btn-danger',
|
|
1097
1512
|
disabled: busy,
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1513
|
+
onClick: () => {
|
|
1514
|
+
// confirm() is standard DOM, guarded anyway: if the embedded
|
|
1515
|
+
// shell omits it, deletion proceeds rather than breaking.
|
|
1516
|
+
if (typeof window.confirm === 'function'
|
|
1517
|
+
&& !window.confirm(t('entry.confirmDelete', {
|
|
1518
|
+
title: entry.title !== '' ? entry.title : t('entry.untitled'),
|
|
1519
|
+
}))) return
|
|
1520
|
+
// Keep the full entry for the undo bar; the POST response
|
|
1521
|
+
// (not this closure) is what confirms the deletion happened.
|
|
1522
|
+
setUndo(null)
|
|
1523
|
+
send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
|
|
1524
|
+
if (result === null || result.ok !== true) return
|
|
1525
|
+
setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
|
|
1526
|
+
})
|
|
1102
1527
|
},
|
|
1103
|
-
}),
|
|
1104
|
-
t('entry.enabled')),
|
|
1105
|
-
h('button', {
|
|
1106
|
-
className: 'im-btn',
|
|
1107
|
-
disabled: busy,
|
|
1108
|
-
onClick: () => { setDraft(cloneEntry(entry)); setError('') },
|
|
1109
|
-
}, t('entry.edit')),
|
|
1110
|
-
h('button', {
|
|
1111
|
-
className: 'im-btn im-btn-danger',
|
|
1112
|
-
disabled: busy,
|
|
1113
|
-
onClick: () => {
|
|
1114
|
-
// confirm() is standard DOM, guarded anyway: if the embedded
|
|
1115
|
-
// shell omits it, deletion proceeds rather than breaking.
|
|
1116
|
-
if (typeof window.confirm === 'function'
|
|
1117
|
-
&& !window.confirm(t('entry.confirmDelete', {
|
|
1118
|
-
title: entry.title !== '' ? entry.title : t('entry.untitled'),
|
|
1119
|
-
}))) return
|
|
1120
|
-
// Keep the full entry for the undo bar; the POST response
|
|
1121
|
-
// (not this closure) is what confirms the deletion happened.
|
|
1122
|
-
setUndo(null)
|
|
1123
|
-
send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
|
|
1124
|
-
if (result === null || result.ok !== true) return
|
|
1125
|
-
setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
|
|
1126
|
-
})
|
|
1127
|
-
},
|
|
1128
|
-
}, t('entry.delete'))),
|
|
1528
|
+
}, t('entry.delete'))),
|
|
1129
1529
|
h(EntryBody, { text: entry.content, key: 'body' }),
|
|
1530
|
+
Array.isArray(entry.tags) && entry.tags.length > 0
|
|
1531
|
+
? h('div', { className: 'im-entry-tags' },
|
|
1532
|
+
entry.tags.map((tag) => h('span', { className: 'im-chip', key: tag }, tag)))
|
|
1533
|
+
: null,
|
|
1130
1534
|
entry.mode === 'auto' && entry.when !== ''
|
|
1131
1535
|
? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
|
|
1132
1536
|
: null,
|
|
@@ -1142,7 +1546,10 @@ window.__ModuleLoader__.load({
|
|
|
1142
1546
|
? h('div', { className: 'im-empty', key: 'empty' },
|
|
1143
1547
|
t('list.empty'))
|
|
1144
1548
|
: visible.length === 0
|
|
1145
|
-
? h('div', { className: 'im-empty', key: 'no-match' },
|
|
1549
|
+
? h('div', { className: 'im-empty', key: 'no-match' },
|
|
1550
|
+
keyword === ''
|
|
1551
|
+
? t('list.filteredEmpty')
|
|
1552
|
+
: t('list.noMatch', { query: query.trim() }))
|
|
1146
1553
|
: h('div', { className: 'im-list', key: 'list' }, items)
|
|
1147
1554
|
|
|
1148
1555
|
return h('div', { className: 'im-root' }, header, list)
|
|
@@ -1232,6 +1639,7 @@ window.__ModuleLoader__.load({
|
|
|
1232
1639
|
exports.__importSizeVerdict = importSizeVerdict
|
|
1233
1640
|
exports.__nextBudgetText = nextBudgetText
|
|
1234
1641
|
exports.__budgetMeter = budgetMeter
|
|
1642
|
+
exports.__parseTagInput = parseTagInput
|
|
1235
1643
|
exports.__strings = STRINGS
|
|
1236
1644
|
exports.__ns = NS
|
|
1237
1645
|
exports.__dictionaryT = dictionaryT
|
package/lib/index.js
CHANGED
|
@@ -92,6 +92,15 @@ const MAX_ENTRIES = 200
|
|
|
92
92
|
const MAX_TITLE = 120
|
|
93
93
|
const MAX_CONTENT = 6000
|
|
94
94
|
const MAX_WHEN = 200
|
|
95
|
+
/** Tags are short grouping labels for the settings page only; they never enter the injected block. */
|
|
96
|
+
const MAX_TAGS_PER_ENTRY = 8
|
|
97
|
+
const MAX_TAG_LENGTH = 24
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The batch operations the `batch` route accepts. Kept here so the command and
|
|
101
|
+
* the route's dispatch share one list.
|
|
102
|
+
*/
|
|
103
|
+
const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'delete']
|
|
95
104
|
|
|
96
105
|
/**
|
|
97
106
|
* Request body ceiling for the settings-page route; anything larger is refused
|
|
@@ -106,7 +115,7 @@ const MAX_WHEN = 200
|
|
|
106
115
|
* the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
|
|
107
116
|
* wrapper.
|
|
108
117
|
*/
|
|
109
|
-
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
|
|
118
|
+
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200) * 2 + 100_000
|
|
110
119
|
|
|
111
120
|
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
112
121
|
const LOCK_STALE_MS = 10_000
|
|
@@ -158,6 +167,34 @@ function priorityLabel(value) {
|
|
|
158
167
|
return '普通'
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Normalise an entry's `tags`: a de-duplicated, single-line, capped string
|
|
172
|
+
* array used ONLY by the settings page for grouping/filtering. Tags never
|
|
173
|
+
* appear in the injected block, so they do not touch buildBlock. Exported for
|
|
174
|
+
* tests and reused by the batch "add-tags" path so both merge through one
|
|
175
|
+
* rule. Non-array input (an old/hand-made entry without tags) is simply no
|
|
176
|
+
* tags, not an error; non-string/blank items are dropped; anything past
|
|
177
|
+
* MAX_TAGS_PER_ENTRY is ignored after de-duplication.
|
|
178
|
+
*/
|
|
179
|
+
export function sanitizeTags(raw) {
|
|
180
|
+
if (!Array.isArray(raw)) return []
|
|
181
|
+
const out = []
|
|
182
|
+
const seen = new Set()
|
|
183
|
+
for (const item of raw) {
|
|
184
|
+
if (typeof item !== 'string') continue
|
|
185
|
+
// Collapse ALL whitespace runs (newlines and repeated spaces) so the same
|
|
186
|
+
// typed label normalises identically whether it came from the editor's
|
|
187
|
+
// tag box, an import, or a hand edit — otherwise "a b" and "a b" would
|
|
188
|
+
// both survive and fail to de-duplicate.
|
|
189
|
+
const tag = singleLine(item, MAX_TAG_LENGTH).replace(/\s+/g, ' ').trim()
|
|
190
|
+
if (tag === '' || seen.has(tag)) continue
|
|
191
|
+
seen.add(tag)
|
|
192
|
+
if (out.length >= MAX_TAGS_PER_ENTRY) break
|
|
193
|
+
out.push(tag)
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
161
198
|
export function sanitizeEntry(raw) {
|
|
162
199
|
if (raw === null || typeof raw !== 'object') return null
|
|
163
200
|
const title = singleLine(raw.title, MAX_TITLE)
|
|
@@ -171,6 +208,7 @@ export function sanitizeEntry(raw) {
|
|
|
171
208
|
when: singleLine(raw.when, MAX_WHEN),
|
|
172
209
|
priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
|
|
173
210
|
enabled: raw.enabled !== false,
|
|
211
|
+
tags: sanitizeTags(raw.tags),
|
|
174
212
|
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
175
213
|
}
|
|
176
214
|
}
|
|
@@ -1539,6 +1577,126 @@ export function apply(ctx) {
|
|
|
1539
1577
|
return settle(null, saved)
|
|
1540
1578
|
})
|
|
1541
1579
|
|
|
1580
|
+
/**
|
|
1581
|
+
* Apply ONE operation to many entries in a single transaction.
|
|
1582
|
+
*
|
|
1583
|
+
* The settings page's multi-select must not loop save-entry/delete-entry:
|
|
1584
|
+
* that would bump `rev`, write the file and broadcast an SSE frame once per
|
|
1585
|
+
* id. Instead the whole batch is planned first (only entries that actually
|
|
1586
|
+
* change are touched — an all-no-op batch, like deleting ids that are not
|
|
1587
|
+
* there, writes nothing and keeps `rev` still), then committed through the
|
|
1588
|
+
* same commit()/writeToDisk() path as every other mutation, so the lock, rev
|
|
1589
|
+
* guard, rollback-on-failed-write and broadcast all apply once.
|
|
1590
|
+
*
|
|
1591
|
+
* `value` semantics per op: set-enabled -> boolean; set-priority -> 0|1|2;
|
|
1592
|
+
* add-tags/remove-tags -> string[] (run through sanitizeTags); delete ->
|
|
1593
|
+
* unused. Batch edits deliberately do NOT re-stamp updatedAt: enabling a
|
|
1594
|
+
* group or tagging it must not reshuffle the within-priority injection
|
|
1595
|
+
* order, which is tied to updatedAt.
|
|
1596
|
+
*/
|
|
1597
|
+
const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
|
|
1598
|
+
if (state.versionRejected) return versionRefusal()
|
|
1599
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1600
|
+
if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
|
|
1601
|
+
return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
|
|
1602
|
+
}
|
|
1603
|
+
const uniqueIds = Array.isArray(ids)
|
|
1604
|
+
? [...new Set(ids.filter((id) => typeof id === 'string' && id !== ''))]
|
|
1605
|
+
: []
|
|
1606
|
+
if (uniqueIds.length === 0) {
|
|
1607
|
+
return { ok: false, saved: false, message: '没有选择任何条目', snapshot: snapshot() }
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const list = state.data.entries
|
|
1611
|
+
const targets = list.filter((entry) => uniqueIds.includes(entry.id))
|
|
1612
|
+
const missing = uniqueIds.length - targets.length
|
|
1613
|
+
const missingNote = missing > 0
|
|
1614
|
+
? '(另有 ' + missing + ' 条未找到,可能已被其他窗口或进程修改)'
|
|
1615
|
+
: ''
|
|
1616
|
+
|
|
1617
|
+
if (op === 'delete') {
|
|
1618
|
+
// Deleting ids that are all absent changes nothing — mirror single
|
|
1619
|
+
// deleteEntry's no-write behaviour rather than manufacturing a save.
|
|
1620
|
+
if (targets.length === 0) {
|
|
1621
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1622
|
+
}
|
|
1623
|
+
const gone = new Set(targets.map((entry) => entry.id))
|
|
1624
|
+
const affected = targets.length
|
|
1625
|
+
const saved = await commit(() => {
|
|
1626
|
+
state.data.entries = state.data.entries.filter((entry) => !gone.has(entry.id))
|
|
1627
|
+
})
|
|
1628
|
+
return settle('已删除 ' + affected + ' 条指令' + missingNote, saved)
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Pure planners: return the patch to apply, or null when this entry is
|
|
1632
|
+
// already at the target state. Nothing is mutated here.
|
|
1633
|
+
let planner
|
|
1634
|
+
if (op === 'set-enabled') {
|
|
1635
|
+
if (typeof value !== 'boolean') {
|
|
1636
|
+
return { ok: false, saved: false, message: '批量启停需要一个布尔值', snapshot: snapshot() }
|
|
1637
|
+
}
|
|
1638
|
+
planner = (entry) => (entry.enabled === value ? null : { enabled: value })
|
|
1639
|
+
} else if (op === 'set-priority') {
|
|
1640
|
+
if (value !== 0 && value !== 1 && value !== 2) {
|
|
1641
|
+
return { ok: false, saved: false, message: '批量优先级只能是 0、1 或 2', snapshot: snapshot() }
|
|
1642
|
+
}
|
|
1643
|
+
planner = (entry) => (entry.priority === value ? null : { priority: value })
|
|
1644
|
+
} else if (op === 'add-tags') {
|
|
1645
|
+
if (!Array.isArray(value)) {
|
|
1646
|
+
return { ok: false, saved: false, message: '批量添加标签需要一个标签数组', snapshot: snapshot() }
|
|
1647
|
+
}
|
|
1648
|
+
const add = sanitizeTags(value)
|
|
1649
|
+
if (add.length === 0) {
|
|
1650
|
+
return { ok: false, saved: false, message: '没有可添加的有效标签(单个标签不超过 ' + MAX_TAG_LENGTH + ' 字符)', snapshot: snapshot() }
|
|
1651
|
+
}
|
|
1652
|
+
planner = (entry) => {
|
|
1653
|
+
const cur = Array.isArray(entry.tags) ? entry.tags : []
|
|
1654
|
+
const merged = sanitizeTags([...cur, ...add])
|
|
1655
|
+
if (merged.length === cur.length && merged.every((tag) => cur.includes(tag))) return null
|
|
1656
|
+
return { tags: merged }
|
|
1657
|
+
}
|
|
1658
|
+
} else {
|
|
1659
|
+
// remove-tags
|
|
1660
|
+
if (!Array.isArray(value)) {
|
|
1661
|
+
return { ok: false, saved: false, message: '批量移除标签需要一个标签数组', snapshot: snapshot() }
|
|
1662
|
+
}
|
|
1663
|
+
const remove = new Set(sanitizeTags(value))
|
|
1664
|
+
if (remove.size === 0) {
|
|
1665
|
+
return { ok: false, saved: false, message: '没有可移除的有效标签', snapshot: snapshot() }
|
|
1666
|
+
}
|
|
1667
|
+
planner = (entry) => {
|
|
1668
|
+
const cur = Array.isArray(entry.tags) ? entry.tags : []
|
|
1669
|
+
const kept = cur.filter((tag) => !remove.has(tag))
|
|
1670
|
+
return kept.length === cur.length ? null : { tags: kept }
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const changes = []
|
|
1675
|
+
for (const entry of targets) {
|
|
1676
|
+
const patch = planner(entry)
|
|
1677
|
+
if (patch !== null) changes.push({ entry, patch })
|
|
1678
|
+
}
|
|
1679
|
+
if (changes.length === 0) {
|
|
1680
|
+
return { ok: true, saved: false, message: '所选条目均已是目标状态' + missingNote, snapshot: snapshot() }
|
|
1681
|
+
}
|
|
1682
|
+
const saved = await commit(() => {
|
|
1683
|
+
for (const change of changes) Object.assign(change.entry, change.patch)
|
|
1684
|
+
})
|
|
1685
|
+
const done = {
|
|
1686
|
+
'set-enabled': value ? '已启用 ' : '已停用 ',
|
|
1687
|
+
'set-priority': '已将 ',
|
|
1688
|
+
'add-tags': '已为 ',
|
|
1689
|
+
'remove-tags': '已从 ',
|
|
1690
|
+
}[op]
|
|
1691
|
+
const tail = {
|
|
1692
|
+
'set-enabled': changes.length + ' 条指令',
|
|
1693
|
+
'set-priority': changes.length + ' 条指令的优先级设为「' + priorityLabel(value) + '」',
|
|
1694
|
+
'add-tags': changes.length + ' 条指令添加标签',
|
|
1695
|
+
'remove-tags': changes.length + ' 条指令移除标签',
|
|
1696
|
+
}[op]
|
|
1697
|
+
return settle(done + tail + missingNote, saved)
|
|
1698
|
+
})
|
|
1699
|
+
|
|
1542
1700
|
const reload = () => enqueue(async () => {
|
|
1543
1701
|
// `saved` is only true when the load itself wrote to disk (the
|
|
1544
1702
|
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
@@ -1661,6 +1819,12 @@ export function apply(ctx) {
|
|
|
1661
1819
|
return importData(
|
|
1662
1820
|
args !== null && typeof args === 'object' ? args.payload : null,
|
|
1663
1821
|
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1822
|
+
case 'batch':
|
|
1823
|
+
return batchEntries(
|
|
1824
|
+
args !== null && typeof args === 'object' ? args.op : null,
|
|
1825
|
+
args !== null && typeof args === 'object' ? args.ids : null,
|
|
1826
|
+
args !== null && typeof args === 'object' ? args.value : undefined,
|
|
1827
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1664
1828
|
default:
|
|
1665
1829
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
1666
1830
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|