dsh-instruction-memory 1.0.16 → 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 +58 -0
- package/README.md +17 -1
- package/lib/client.js +549 -45
- package/lib/index.js +247 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,64 @@
|
|
|
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
|
+
|
|
38
|
+
## [1.0.17] — 2026-10-12
|
|
39
|
+
|
|
40
|
+
### 新增
|
|
41
|
+
|
|
42
|
+
- **注入预算可视化(F1)**:设置页在「注入字数上限」下方显示用量条与「实际注入 x / y 字符
|
|
43
|
+
(约 z token,估算)」(token 按 0.6 系数估算,与既有说明同一口径,仅作参考);被上限略过的
|
|
44
|
+
**生效中**条目在列表中逐条标注橙色「本轮不注入」(悬停说明原因),极端情况下首条被迫截断时
|
|
45
|
+
标注红色「已截断」,用量条同时变为警示色并给出条数提示。调高上限或调整优先级保存后标记即时变化。
|
|
46
|
+
- Host 新增导出纯函数 `planInjection(data)`:在**不改变注入文本一个字节**的前提下,返回注入文本、
|
|
47
|
+
生效上限(clamp 后的 budget)、候选条目数,以及每条 `included`(`{id, chars, truncated}`)
|
|
48
|
+
/ `omitted`(`{id, chars}`)的取舍明细与考虑顺序(先「始终」后「按需」、同档优先级/更新时间降序);
|
|
49
|
+
`buildBlock()` 改为它的薄封装。
|
|
50
|
+
- 快照 `injection` 新增 `budget` / `activeCount` / `included` / `omitted` 字段(SSE 帧随之自动携带)。
|
|
51
|
+
存储格式仍为 v1、**无新增落盘字段**;旧客户端收到新字段自然忽略,新客户端对没有这些字段的旧宿主
|
|
52
|
+
快照回退到 `data.budgetChars`、不显示条目标记,两侧均不报错。
|
|
53
|
+
|
|
54
|
+
### 测试
|
|
55
|
+
|
|
56
|
+
- `smoke-test.mjs` 新增 20+ 条 `planInjection` 断言:空/总开关关闭/非法输入、budget 越界夹取、
|
|
57
|
+
与 `buildBlock()` 逐字节一致、停用与空条目不参与、「始终」先于「按需」、紧预算下 included/omitted
|
|
58
|
+
恰好分区全部候选、单条超长首条截断标记、计划本身确定性。
|
|
59
|
+
- `contract-test.mjs` 新增独立验证块:经真实路由在 800 字符上限下断言快照预算、`chars === preview.length`
|
|
60
|
+
且不超上限、取舍明细恰好覆盖全部生效条目且不含停用条目、预览脚注数量与 omitted 一致、提高上限后
|
|
61
|
+
全部纳入;并把真实/旧版/垃圾三类快照喂给客户端 reducer 验证接缝。
|
|
62
|
+
- `verify.mjs` 新增客户端 `__budgetMeter` reducer 断言(计划字段优先、旧快照回退、非法项忽略、
|
|
63
|
+
垃圾输入零值不抛错、percent 封顶 100、0.6 token 估算)及 6 个新 i18n 键的双语存在性;
|
|
64
|
+
键集与占位符一致性仍由既有用例锁定。
|
|
65
|
+
- `docs/demo.html` 的演示宿主改为实时计算注入计划(与 `planInjection` 同一算法),演示数据
|
|
66
|
+
默认 800 字符上限以直接展示用量条与「本轮不注入」标记;旧的手工静态预览文本移除。
|
|
67
|
+
|
|
10
68
|
## [1.0.16] — 2026-10-05
|
|
11
69
|
|
|
12
70
|
一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
|
package/README.md
CHANGED
|
@@ -54,6 +54,19 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
54
54
|
上限单位是**字符**而非 token——按 DeepSeek 官方分词比例约 1 个中文字符 ≈ 0.6 token、
|
|
55
55
|
1 个英文字符 ≈ 0.3 token(不同模型分词有差异,实际用量以模型返回为准),上限 40000
|
|
56
56
|
字符约合 24000 token。
|
|
57
|
+
- **预算用量可视化**:设置页在上限输入框下方显示用量条——「实际注入 x / y 字符(约 z token,
|
|
58
|
+
估算)」(token 数按 0.6 系数估算,仅作参考);被上限略过的**生效中**条目会在列表里逐条
|
|
59
|
+
标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。
|
|
60
|
+
调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的**展示**,不改变注入文本本身;
|
|
61
|
+
存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
|
|
62
|
+
- **标签、筛选与排序**:每条指令可加若干**标签**(回车或逗号添加,最多 8 个、单个不超过 24 字符)。
|
|
63
|
+
标签只用于设置页分组与筛选,**不会进入注入文本**;标签缺失的旧记忆照常加载(等同无标签)。
|
|
64
|
+
列表可按标签、生效方式(始终/按需)、启用状态组合筛选,并在「按优先级 / 按更新时间 / 按标题」
|
|
65
|
+
之间切换排序;列表排序纯为查看方便,不改变宿主侧的注入顺序。
|
|
66
|
+
- **批量操作**:点「批量操作」进入多选,可对勾选条目一次性启用、停用、设置优先级、加标签或删除;
|
|
67
|
+
多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是**单次写入**
|
|
68
|
+
(只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。
|
|
69
|
+
批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。
|
|
57
70
|
|
|
58
71
|
条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
|
|
59
72
|
|
|
@@ -432,6 +445,7 @@ Package → Settings → Trusted publishing → Add trusted publisher
|
|
|
432
445
|
"when": "",
|
|
433
446
|
"priority": 1,
|
|
434
447
|
"enabled": true,
|
|
448
|
+
"tags": ["沟通"],
|
|
435
449
|
"updatedAt": 1700000000000
|
|
436
450
|
}
|
|
437
451
|
]
|
|
@@ -439,4 +453,6 @@ Package → Settings → Trusted publishing → Add trusted publisher
|
|
|
439
453
|
```
|
|
440
454
|
|
|
441
455
|
`mode` 为 `always` | `auto`;`priority` 为 `2` 高 / `1` 普通 / `0` 低。`rev` 是存储修订号,
|
|
442
|
-
由插件在每次成功写入时递增,**无需手填**(缺失视为 0
|
|
456
|
+
由插件在每次成功写入时递增,**无需手填**(缺失视为 0)。`tags` 为可选的短标签字符串数组
|
|
457
|
+
(最多 8 个、单个不超过 24 字符,仅用于设置页筛选,不参与注入);缺失或非数组一律按无标签处理,
|
|
458
|
+
非法项会被剔除、超长会被截断、重复项会去重,格式版本号仍为 `1`。
|
package/lib/client.js
CHANGED
|
@@ -58,6 +58,31 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
.im-tag-always { color:var(--dsw-alias-brand-primary,#3b6cff); border-color:currentColor; }
|
|
59
59
|
.im-tag-auto { color:var(--dsw-alias-state-success-primary,#18a058); border-color:currentColor; }
|
|
60
60
|
.im-tag-high { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
|
|
61
|
+
.im-tag-off { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
|
|
62
|
+
.im-tag-cut { color:var(--dsw-alias-state-error-primary,#d94838); border-color:currentColor; }
|
|
63
|
+
.im-meter { height:6px; border-radius:999px; overflow:hidden;
|
|
64
|
+
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
|
|
65
|
+
.im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
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); }
|
|
61
86
|
.im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
|
|
62
87
|
border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
63
88
|
.im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
|
|
@@ -79,6 +104,7 @@ window.__ModuleLoader__.load({
|
|
|
79
104
|
.im-body-open { max-height:none; }
|
|
80
105
|
.im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
|
|
81
106
|
.im-search-meta { margin-top:5px; }
|
|
107
|
+
.im-meter-line { margin-top:5px; }
|
|
82
108
|
.im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
|
|
83
109
|
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)); }
|
|
84
110
|
.im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
|
|
@@ -140,10 +166,47 @@ window.__ModuleLoader__.load({
|
|
|
140
166
|
'budget.unitHint': '单位是字符,不是 token',
|
|
141
167
|
'budget.rangeError': '上限需在 {min} ~ {max} 之间',
|
|
142
168
|
'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
|
|
169
|
+
'budget.used': '实际注入 {used} / {budget} 字符(约 {tokens} token,估算)',
|
|
170
|
+
'budget.omitted': '有 {count} 条生效指令因超出上限本轮未注入,可调高上限或调整优先级',
|
|
171
|
+
'tag.omitted': '本轮不注入',
|
|
172
|
+
'tag.truncated': '已截断',
|
|
173
|
+
'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
|
|
174
|
+
'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
|
|
143
175
|
'rules.label': '生效规则',
|
|
144
176
|
'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
|
|
145
177
|
'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
|
|
146
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': '当前环境不支持快捷输入,请逐条编辑标签',
|
|
147
210
|
'action.new': '+ 新增指令',
|
|
148
211
|
'action.reload': '重新载入',
|
|
149
212
|
'action.export': '导出',
|
|
@@ -160,6 +223,7 @@ window.__ModuleLoader__.load({
|
|
|
160
223
|
'preview.empty': '(当前没有生效中的指令)',
|
|
161
224
|
'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
|
|
162
225
|
'list.noMatch': '没有匹配“{query}”的指令。',
|
|
226
|
+
'list.filteredEmpty': '没有符合当前筛选条件的指令。',
|
|
163
227
|
'tag.always': '始终',
|
|
164
228
|
'tag.auto': '按需',
|
|
165
229
|
'entry.enabled': '启用',
|
|
@@ -217,10 +281,47 @@ window.__ModuleLoader__.load({
|
|
|
217
281
|
'budget.unitHint': 'The unit is characters, not tokens',
|
|
218
282
|
'budget.rangeError': 'The limit must be between {min} and {max}',
|
|
219
283
|
'budget.note': 'Characters, not tokens: with DeepSeek tokenization roughly 1 Chinese character ≈ 0.6 token (English ≈ 0.3), the {max}-character ceiling is about {tokens} tokens; actual usage depends on the model.',
|
|
284
|
+
'budget.used': '{used} of {budget} characters actually injected (≈{tokens} tokens, estimate)',
|
|
285
|
+
'budget.omitted': '{count} active entries are not injected this turn because the limit is exceeded; raise the limit or adjust priorities',
|
|
286
|
+
'tag.omitted': 'Not injected',
|
|
287
|
+
'tag.truncated': 'Truncated',
|
|
288
|
+
'tag.omittedTitle': 'This entry currently stays out of the system prompt because the injection size limit is exceeded',
|
|
289
|
+
'tag.truncatedTitle': 'This entry is too long; only its beginning reaches the system prompt',
|
|
220
290
|
'rules.label': 'How instructions apply',
|
|
221
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.',
|
|
222
292
|
'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
|
|
223
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',
|
|
224
325
|
'action.new': '+ New instruction',
|
|
225
326
|
'action.reload': 'Reload',
|
|
226
327
|
'action.export': 'Export',
|
|
@@ -237,6 +338,7 @@ window.__ModuleLoader__.load({
|
|
|
237
338
|
'preview.empty': '(no active instructions right now)',
|
|
238
339
|
'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
|
|
239
340
|
'list.noMatch': 'No instructions match “{query}”.',
|
|
341
|
+
'list.filteredEmpty': 'No instructions match the current filters.',
|
|
240
342
|
'tag.always': 'Always',
|
|
241
343
|
'tag.auto': 'On demand',
|
|
242
344
|
'entry.enabled': 'Enabled',
|
|
@@ -377,6 +479,32 @@ window.__ModuleLoader__.load({
|
|
|
377
479
|
|
|
378
480
|
/* ---------------- data helpers ---------------- */
|
|
379
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
|
+
|
|
380
508
|
function cloneEntry(entry) {
|
|
381
509
|
return {
|
|
382
510
|
id: entry.id,
|
|
@@ -386,12 +514,13 @@ window.__ModuleLoader__.load({
|
|
|
386
514
|
when: entry.when,
|
|
387
515
|
priority: entry.priority,
|
|
388
516
|
enabled: entry.enabled,
|
|
517
|
+
tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
|
|
389
518
|
updatedAt: entry.updatedAt,
|
|
390
519
|
}
|
|
391
520
|
}
|
|
392
521
|
|
|
393
522
|
function blankDraft() {
|
|
394
|
-
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 }
|
|
395
524
|
}
|
|
396
525
|
|
|
397
526
|
/**
|
|
@@ -409,7 +538,11 @@ window.__ModuleLoader__.load({
|
|
|
409
538
|
const fields = ['title', 'content', 'mode', 'when', 'priority', 'enabled']
|
|
410
539
|
const base = draft.id === '' ? blankDraft() : (entries || []).find((entry) => entry.id === draft.id)
|
|
411
540
|
if (base === undefined) return true
|
|
412
|
-
|
|
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)
|
|
413
546
|
}
|
|
414
547
|
|
|
415
548
|
/**
|
|
@@ -497,6 +630,41 @@ window.__ModuleLoader__.load({
|
|
|
497
630
|
return focused ? currentText : String(incoming)
|
|
498
631
|
}
|
|
499
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Reduce a snapshot's injection plan to the values the header meter and
|
|
635
|
+
* the entry badges render. Pinned as a pure export so the fallback math is
|
|
636
|
+
* testable without React.
|
|
637
|
+
*
|
|
638
|
+
* Tolerates an older Host whose snapshot predates the plan fields: the
|
|
639
|
+
* ceiling then falls back to data.budgetChars and there are simply no
|
|
640
|
+
* per-entry marks (included/omitted). Garbage input degrades to zeros
|
|
641
|
+
* instead of throwing mid-render.
|
|
642
|
+
*/
|
|
643
|
+
function budgetMeter(snapshot) {
|
|
644
|
+
const inj = snapshot !== null && typeof snapshot === 'object'
|
|
645
|
+
&& snapshot.injection !== null && typeof snapshot.injection === 'object'
|
|
646
|
+
? snapshot.injection : {}
|
|
647
|
+
const data = snapshot !== null && typeof snapshot === 'object'
|
|
648
|
+
&& snapshot.data !== null && typeof snapshot.data === 'object'
|
|
649
|
+
? snapshot.data : {}
|
|
650
|
+
const budget = typeof inj.budget === 'number' && inj.budget > 0
|
|
651
|
+
? inj.budget
|
|
652
|
+
: (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
|
|
653
|
+
const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
|
|
654
|
+
const countFlagged = (list, wantTruncated) =>
|
|
655
|
+
Array.isArray(list)
|
|
656
|
+
? list.filter((item) => item !== null && typeof item === 'object'
|
|
657
|
+
&& typeof item.id === 'string' && item.id !== ''
|
|
658
|
+
&& (wantTruncated ? item.truncated === true : true)).length
|
|
659
|
+
: 0
|
|
660
|
+
const omitted = countFlagged(inj.omitted, false)
|
|
661
|
+
const truncated = countFlagged(inj.included, true)
|
|
662
|
+
const percent = budget > 0 ? Math.min(100, Math.round((used / budget) * 100)) : 0
|
|
663
|
+
// The same rough ratio the budget note quotes; displayed as an ESTIMATE.
|
|
664
|
+
const tokens = Math.round(used * 0.6)
|
|
665
|
+
return { budget, used, omitted, truncated, percent, tokens }
|
|
666
|
+
}
|
|
667
|
+
|
|
500
668
|
async function callHost(method, args) {
|
|
501
669
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
502
670
|
// after a generous timeout instead.
|
|
@@ -588,6 +756,13 @@ window.__ModuleLoader__.load({
|
|
|
588
756
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
589
757
|
})))
|
|
590
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
|
+
})))
|
|
591
766
|
children.push(h('div', { className: 'im-row-wrap', key: 'a' },
|
|
592
767
|
h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
|
|
593
768
|
props.busy ? t('editor.saving') : t('editor.save')),
|
|
@@ -597,6 +772,73 @@ window.__ModuleLoader__.load({
|
|
|
597
772
|
return h('div', { className: 'im-card' }, children)
|
|
598
773
|
}
|
|
599
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
|
+
|
|
600
842
|
/**
|
|
601
843
|
* Entry body with an explicit 展开/收起 toggle. The collapsed cap lives in
|
|
602
844
|
* CSS (max-height + overflow:hidden), so whether overflow actually happens
|
|
@@ -632,6 +874,14 @@ window.__ModuleLoader__.load({
|
|
|
632
874
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
633
875
|
const [query, setQuery] = React.useState('')
|
|
634
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([])
|
|
635
885
|
const importInputRef = React.useRef(null)
|
|
636
886
|
// Whether the budget input is being edited right now. A live SSE push
|
|
637
887
|
// from another window must not replace the number under the cursor;
|
|
@@ -716,6 +966,54 @@ window.__ModuleLoader__.load({
|
|
|
716
966
|
}
|
|
717
967
|
}
|
|
718
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
|
+
|
|
719
1017
|
async function doExport() {
|
|
720
1018
|
try {
|
|
721
1019
|
const result = await callHost('export-data', null)
|
|
@@ -835,15 +1133,73 @@ window.__ModuleLoader__.load({
|
|
|
835
1133
|
const storage = view.storage || {}
|
|
836
1134
|
const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
|
|
837
1135
|
const injection = view.injection || { registered: false, chars: 0, error: null }
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
})()
|
|
1168
|
+
// Budget usage + per-entry marks straight from the Host's injection plan.
|
|
1169
|
+
// Older hosts simply publish none of these fields: the meter falls back
|
|
1170
|
+
// to data.budgetChars and the maps stay empty.
|
|
1171
|
+
const meter = budgetMeter(view)
|
|
1172
|
+
const omittedById = new Map()
|
|
1173
|
+
const truncatedById = new Map()
|
|
1174
|
+
{
|
|
1175
|
+
const planItems = injection && typeof injection === 'object' ? injection : {}
|
|
1176
|
+
for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
|
|
1177
|
+
if (item !== null && typeof item === 'object' && typeof item.id === 'string') omittedById.set(item.id, item)
|
|
1178
|
+
}
|
|
1179
|
+
for (const item of Array.isArray(planItems.included) ? planItems.included : []) {
|
|
1180
|
+
if (item !== null && typeof item === 'object' && item.truncated === true && typeof item.id === 'string') {
|
|
1181
|
+
truncatedById.set(item.id, item)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
840
1185
|
const keyword = query.trim().toLowerCase()
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
845
1199
|
|| entry.content.toLowerCase().indexOf(keyword) !== -1
|
|
846
|
-
|| entry.when.toLowerCase().indexOf(keyword) !== -1
|
|
1200
|
+
|| entry.when.toLowerCase().indexOf(keyword) !== -1
|
|
1201
|
+
|| inTags.indexOf(keyword) !== -1
|
|
1202
|
+
})
|
|
847
1203
|
|
|
848
1204
|
const dotClass = injection.error
|
|
849
1205
|
? 'im-dot im-dot-err'
|
|
@@ -902,6 +1258,112 @@ window.__ModuleLoader__.load({
|
|
|
902
1258
|
h('label', { className: 'im-label' }, t('rules.label')),
|
|
903
1259
|
h('div', { className: 'im-meta' },
|
|
904
1260
|
t('rules.note')))),
|
|
1261
|
+
(data.enabled === true && activeCount > 0)
|
|
1262
|
+
? h('div', { key: 'meter' },
|
|
1263
|
+
h('div', { className: 'im-meter' },
|
|
1264
|
+
h('div', {
|
|
1265
|
+
className: 'im-meter-fill' + (meter.omitted > 0 ? ' im-meter-fill-warn' : ''),
|
|
1266
|
+
style: { width: meter.percent + '%' },
|
|
1267
|
+
})),
|
|
1268
|
+
h('div', { className: 'im-meta im-meter-line' },
|
|
1269
|
+
t('budget.used', { used: meter.used, budget: meter.budget, tokens: meter.tokens })),
|
|
1270
|
+
meter.omitted > 0
|
|
1271
|
+
? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
|
|
1272
|
+
: null)
|
|
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,
|
|
905
1367
|
entries.length > 0 ? h('div', { key: 'search' },
|
|
906
1368
|
h('input', {
|
|
907
1369
|
className: 'im-input',
|
|
@@ -909,7 +1371,9 @@ window.__ModuleLoader__.load({
|
|
|
909
1371
|
placeholder: t('search.placeholder', { count: entries.length }),
|
|
910
1372
|
onChange: (ev) => setQuery(ev.target.value),
|
|
911
1373
|
}),
|
|
912
|
-
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,
|
|
913
1377
|
h('div', { className: 'im-row-wrap' },
|
|
914
1378
|
h('button', {
|
|
915
1379
|
className: 'im-btn im-btn-primary',
|
|
@@ -931,6 +1395,11 @@ window.__ModuleLoader__.load({
|
|
|
931
1395
|
disabled: busy,
|
|
932
1396
|
onClick: () => setShowPreview(!showPreview),
|
|
933
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')),
|
|
934
1403
|
h('span', { className: 'im-spacer' }),
|
|
935
1404
|
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
936
1405
|
h('div', { className: 'im-divider' }),
|
|
@@ -987,51 +1456,81 @@ window.__ModuleLoader__.load({
|
|
|
987
1456
|
]
|
|
988
1457
|
if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
|
|
989
1458
|
else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
|
|
1459
|
+
// Budget marks from the Host's injection plan. Only active entries can
|
|
1460
|
+
// appear in the plans, so a disabled entry never gets a stale mark.
|
|
1461
|
+
if (omittedById.has(entry.id)) {
|
|
1462
|
+
tags.push(h('span', {
|
|
1463
|
+
className: 'im-tag im-tag-off', key: 'off', title: t('tag.omittedTitle'),
|
|
1464
|
+
}, t('tag.omitted')))
|
|
1465
|
+
} else if (truncatedById.has(entry.id)) {
|
|
1466
|
+
tags.push(h('span', {
|
|
1467
|
+
className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
|
|
1468
|
+
}, t('tag.truncated')))
|
|
1469
|
+
}
|
|
990
1470
|
return h('div', {
|
|
991
1471
|
className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
|
|
992
1472
|
key: entry.id,
|
|
993
1473
|
},
|
|
994
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,
|
|
995
1484
|
h('span', { className: 'im-title' }, entry.title),
|
|
996
1485
|
tags,
|
|
997
1486
|
h('span', { className: 'im-spacer' }),
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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',
|
|
1002
1512
|
disabled: busy,
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
+
})
|
|
1007
1527
|
},
|
|
1008
|
-
}),
|
|
1009
|
-
t('entry.enabled')),
|
|
1010
|
-
h('button', {
|
|
1011
|
-
className: 'im-btn',
|
|
1012
|
-
disabled: busy,
|
|
1013
|
-
onClick: () => { setDraft(cloneEntry(entry)); setError('') },
|
|
1014
|
-
}, t('entry.edit')),
|
|
1015
|
-
h('button', {
|
|
1016
|
-
className: 'im-btn im-btn-danger',
|
|
1017
|
-
disabled: busy,
|
|
1018
|
-
onClick: () => {
|
|
1019
|
-
// confirm() is standard DOM, guarded anyway: if the embedded
|
|
1020
|
-
// shell omits it, deletion proceeds rather than breaking.
|
|
1021
|
-
if (typeof window.confirm === 'function'
|
|
1022
|
-
&& !window.confirm(t('entry.confirmDelete', {
|
|
1023
|
-
title: entry.title !== '' ? entry.title : t('entry.untitled'),
|
|
1024
|
-
}))) return
|
|
1025
|
-
// Keep the full entry for the undo bar; the POST response
|
|
1026
|
-
// (not this closure) is what confirms the deletion happened.
|
|
1027
|
-
setUndo(null)
|
|
1028
|
-
send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
|
|
1029
|
-
if (result === null || result.ok !== true) return
|
|
1030
|
-
setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
|
|
1031
|
-
})
|
|
1032
|
-
},
|
|
1033
|
-
}, t('entry.delete'))),
|
|
1528
|
+
}, t('entry.delete'))),
|
|
1034
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,
|
|
1035
1534
|
entry.mode === 'auto' && entry.when !== ''
|
|
1036
1535
|
? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
|
|
1037
1536
|
: null,
|
|
@@ -1047,7 +1546,10 @@ window.__ModuleLoader__.load({
|
|
|
1047
1546
|
? h('div', { className: 'im-empty', key: 'empty' },
|
|
1048
1547
|
t('list.empty'))
|
|
1049
1548
|
: visible.length === 0
|
|
1050
|
-
? 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() }))
|
|
1051
1553
|
: h('div', { className: 'im-list', key: 'list' }, items)
|
|
1052
1554
|
|
|
1053
1555
|
return h('div', { className: 'im-root' }, header, list)
|
|
@@ -1136,6 +1638,8 @@ window.__ModuleLoader__.load({
|
|
|
1136
1638
|
exports.__confirmDiscard = confirmDiscard
|
|
1137
1639
|
exports.__importSizeVerdict = importSizeVerdict
|
|
1138
1640
|
exports.__nextBudgetText = nextBudgetText
|
|
1641
|
+
exports.__budgetMeter = budgetMeter
|
|
1642
|
+
exports.__parseTagInput = parseTagInput
|
|
1139
1643
|
exports.__strings = STRINGS
|
|
1140
1644
|
exports.__ns = NS
|
|
1141
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
|
}
|
|
@@ -518,19 +556,41 @@ function formatEntry(entry, tag) {
|
|
|
518
556
|
}
|
|
519
557
|
|
|
520
558
|
/**
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
559
|
+
* Build the injection plan: the exact text assembled into the system prompt
|
|
560
|
+
* PLUS the per-entry decisions behind it, so the settings page can show how
|
|
561
|
+
* much of the character budget is in use and flag the enabled entries the
|
|
562
|
+
* current budget leaves out.
|
|
563
|
+
*
|
|
564
|
+
* Deliberately deterministic: identical memory produces a byte-identical
|
|
565
|
+
* plan on every step, so the prompt prefix stays cacheable across a session.
|
|
566
|
+
*
|
|
567
|
+
* Returned shape:
|
|
568
|
+
* {
|
|
569
|
+
* text: string — exactly what buildBlock() returns
|
|
570
|
+
* budget: number — the clamped budgetChars in effect
|
|
571
|
+
* activeCount: number — enabled, non-blank candidates considered
|
|
572
|
+
* included: [{ id, chars, truncated }] — entries that reach the prompt,
|
|
573
|
+
* in consideration order (始终 first, then 按需; within
|
|
574
|
+
* each, priority desc then updatedAt desc); chars is the
|
|
575
|
+
* rendered length of that entry's block (its cut length
|
|
576
|
+
* when truncated)
|
|
577
|
+
* omitted: [{ id, chars }] — active entries the budget could not fit;
|
|
578
|
+
* chars is the full rendered block length they would need
|
|
579
|
+
* }
|
|
580
|
+
*
|
|
581
|
+
* Disabled or blank entries are candidates for neither list: they are not
|
|
582
|
+
* competing for the budget at all. When nothing renders, `text` is '' and
|
|
583
|
+
* `included` is [] even though `budget` still reports the effective ceiling.
|
|
524
584
|
*/
|
|
525
|
-
export function
|
|
526
|
-
|
|
585
|
+
export function planInjection(data) {
|
|
586
|
+
const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
|
|
587
|
+
const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
|
|
588
|
+
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
527
589
|
const active = (Array.isArray(data.entries) ? data.entries : [])
|
|
528
590
|
.filter((entry) => entry.enabled === true && entry.content !== '')
|
|
529
|
-
if (active.length === 0) return
|
|
591
|
+
if (active.length === 0) return blank()
|
|
530
592
|
active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
|
|
531
593
|
|
|
532
|
-
const budget = clampBudget(data.budgetChars)
|
|
533
|
-
|
|
534
594
|
// Richest preamble that still leaves room for one real entry plus the
|
|
535
595
|
// worst-case omission footer. A tight budget degrades the preamble instead of
|
|
536
596
|
// silently injecting nothing.
|
|
@@ -565,6 +625,8 @@ export function buildBlock(data) {
|
|
|
565
625
|
}
|
|
566
626
|
|
|
567
627
|
const blocks = []
|
|
628
|
+
const included = []
|
|
629
|
+
const omittedPlan = []
|
|
568
630
|
let used = header.length
|
|
569
631
|
let omitted = 0
|
|
570
632
|
|
|
@@ -576,6 +638,7 @@ export function buildBlock(data) {
|
|
|
576
638
|
if (used + block.length + 2 + reserve <= budget) {
|
|
577
639
|
used += block.length + 2
|
|
578
640
|
blocks.push(block)
|
|
641
|
+
included.push({ id: entry.id, chars: block.length, truncated: false })
|
|
579
642
|
return
|
|
580
643
|
}
|
|
581
644
|
// The first entry always gets in, truncated if the budget demands it: a
|
|
@@ -593,17 +656,36 @@ export function buildBlock(data) {
|
|
|
593
656
|
const body = head + note
|
|
594
657
|
blocks.push(body)
|
|
595
658
|
used += body.length + 2
|
|
659
|
+
included.push({ id: entry.id, chars: body.length, truncated: true })
|
|
596
660
|
return
|
|
597
661
|
}
|
|
598
662
|
}
|
|
599
663
|
omitted += 1
|
|
664
|
+
omittedPlan.push({ id: entry.id, chars: block.length })
|
|
600
665
|
}
|
|
601
666
|
|
|
602
667
|
for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
|
|
603
668
|
for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
|
|
604
669
|
|
|
605
|
-
if (blocks.length === 0)
|
|
606
|
-
|
|
670
|
+
if (blocks.length === 0) {
|
|
671
|
+
return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
text: header + blocks.join('\n\n') + footerFor(omitted),
|
|
675
|
+
budget,
|
|
676
|
+
activeCount: active.length,
|
|
677
|
+
included,
|
|
678
|
+
omitted: omittedPlan,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Render the injected block text. A thin wrapper over planInjection: the
|
|
684
|
+
* decision process lives in one place, and callers that only need the text
|
|
685
|
+
* (the prompt section) keep this byte-stable entry point.
|
|
686
|
+
*/
|
|
687
|
+
export function buildBlock(data) {
|
|
688
|
+
return planInjection(data).text
|
|
607
689
|
}
|
|
608
690
|
|
|
609
691
|
/* ------------------------------------------------------------------ *
|
|
@@ -725,6 +807,22 @@ export function apply(ctx) {
|
|
|
725
807
|
}
|
|
726
808
|
}
|
|
727
809
|
|
|
810
|
+
/**
|
|
811
|
+
* The full injection plan (text + per-entry budget decisions) behind the
|
|
812
|
+
* registered section. The snapshot publishes it so the settings page can
|
|
813
|
+
* show budget usage and mark the enabled entries that currently do not fit.
|
|
814
|
+
* A render failure degrades to an empty plan at the effective ceiling
|
|
815
|
+
* rather than throwing into the route/SSE path.
|
|
816
|
+
*/
|
|
817
|
+
const sectionPlan = () => {
|
|
818
|
+
try {
|
|
819
|
+
return planInjection(state.data)
|
|
820
|
+
} catch (err) {
|
|
821
|
+
console.error('[instruction-memory] render failed:', err)
|
|
822
|
+
return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
728
826
|
/**
|
|
729
827
|
* The thunk handed to the harness.
|
|
730
828
|
*
|
|
@@ -1287,7 +1385,8 @@ export function apply(ctx) {
|
|
|
1287
1385
|
/* ---------------- snapshots ---------------- */
|
|
1288
1386
|
|
|
1289
1387
|
const snapshot = () => {
|
|
1290
|
-
const
|
|
1388
|
+
const plan = sectionPlan()
|
|
1389
|
+
const preview = plan.text
|
|
1291
1390
|
return {
|
|
1292
1391
|
data: {
|
|
1293
1392
|
version: 1,
|
|
@@ -1310,6 +1409,16 @@ export function apply(ctx) {
|
|
|
1310
1409
|
available: true,
|
|
1311
1410
|
error: state.sectionError,
|
|
1312
1411
|
chars: preview.length,
|
|
1412
|
+
/**
|
|
1413
|
+
* The budget decision behind `preview`, for the settings page's
|
|
1414
|
+
* usage meter and per-entry marks. `budget` is the effective ceiling;
|
|
1415
|
+
* `included`/`omitted` carry one item per active entry in
|
|
1416
|
+
* consideration order; disabled or blank entries appear in neither.
|
|
1417
|
+
*/
|
|
1418
|
+
budget: plan.budget,
|
|
1419
|
+
activeCount: plan.activeCount,
|
|
1420
|
+
included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
|
|
1421
|
+
omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
|
|
1313
1422
|
/**
|
|
1314
1423
|
* How many times the harness has pulled this section into a prompt since
|
|
1315
1424
|
* boot. 0 means "prepared, never assembled" — the distinction the old
|
|
@@ -1468,6 +1577,126 @@ export function apply(ctx) {
|
|
|
1468
1577
|
return settle(null, saved)
|
|
1469
1578
|
})
|
|
1470
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
|
+
|
|
1471
1700
|
const reload = () => enqueue(async () => {
|
|
1472
1701
|
// `saved` is only true when the load itself wrote to disk (the
|
|
1473
1702
|
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
@@ -1590,6 +1819,12 @@ export function apply(ctx) {
|
|
|
1590
1819
|
return importData(
|
|
1591
1820
|
args !== null && typeof args === 'object' ? args.payload : null,
|
|
1592
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)
|
|
1593
1828
|
default:
|
|
1594
1829
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
1595
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",
|