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 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
- return fields.some((field) => String(draft[field]) !== String(base[field]))
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
- const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
893
- const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
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 visible = keyword === ''
913
- ? entries
914
- : entries.filter((entry) =>
915
- entry.title.toLowerCase().indexOf(keyword) !== -1
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() !== '' ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length })) : null) : null,
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
- h('label', { className: 'im-switch' },
1094
- h('input', {
1095
- type: 'checkbox',
1096
- checked: entry.enabled === true,
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
- onChange: () => {
1099
- const next = cloneEntry(entry)
1100
- next.enabled = !entry.enabled
1101
- send('save-entry', { entry: next, baseRev: revOf() })
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' }, t('list.noMatch', { query: query.trim() }))
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.17",
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",