dsh-instruction-memory 1.0.14 → 1.0.16
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 +318 -0
- package/README.md +97 -12
- package/lib/client.js +645 -78
- package/lib/index.js +671 -50
- package/package.json +7 -5
package/lib/client.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* (`window.__ModuleLoader__.load` + `require`), so the package needs no build
|
|
6
6
|
* step. Contributes one settings page; all data lives host-side and is reached
|
|
7
7
|
* through the same-origin route the Host half registers.
|
|
8
|
+
*
|
|
9
|
+
* UI copy is bilingual (zh/en). When the host ships the official locale
|
|
10
|
+
* service (`ctx.locale`, from @deepseek-ai/dsh-client-locale) the panel
|
|
11
|
+
* follows the user's Language preference live; otherwise the built-in
|
|
12
|
+
* dictionaries pick the browser's language (zh by default).
|
|
8
13
|
*/
|
|
9
14
|
window.__ModuleLoader__.load({
|
|
10
15
|
id: 'dsh-instruction-memory',
|
|
@@ -74,9 +79,304 @@ window.__ModuleLoader__.load({
|
|
|
74
79
|
.im-body-open { max-height:none; }
|
|
75
80
|
.im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
|
|
76
81
|
.im-search-meta { margin-top:5px; }
|
|
82
|
+
.im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
|
|
83
|
+
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
|
+
.im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
|
|
85
|
+
background:transparent; border-radius:7px; padding:2px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
|
|
77
86
|
@media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
|
|
78
87
|
`
|
|
79
88
|
|
|
89
|
+
/* ---------------- i18n ---------------- */
|
|
90
|
+
|
|
91
|
+
/** Our slice of the dictionary registry; one owner per namespace. */
|
|
92
|
+
const NS = 'instruction-memory'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The full UI copy, both shipped locales, identical key sets. zh is the
|
|
96
|
+
* fallback locale (mirrors the official FALLBACK_LOCALE), so a missing en
|
|
97
|
+
* entry must be a build mistake — verify.mjs pins the key sets equal.
|
|
98
|
+
*/
|
|
99
|
+
const STRINGS = {
|
|
100
|
+
zh: {
|
|
101
|
+
'section.label': '指令记忆',
|
|
102
|
+
'panel.title': '指令记忆',
|
|
103
|
+
'panel.subtitle': '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。',
|
|
104
|
+
'panel.loading': '正在读取指令记忆…',
|
|
105
|
+
'panel.masterSwitch': '总开关',
|
|
106
|
+
'status.mounted': '已挂载到系统提示词——后续每轮对话自动生效({chars} 字符)',
|
|
107
|
+
'status.notMountedEmpty': '未挂载:当前没有启用中的指令',
|
|
108
|
+
'status.notMounted': '未挂载',
|
|
109
|
+
'err.unrecognized': '宿主返回了无法识别的数据',
|
|
110
|
+
'err.http': '宿主返回 HTTP {status}',
|
|
111
|
+
'err.httpDetail': '宿主返回 HTTP {status}:{detail}',
|
|
112
|
+
'err.timeout': '请求宿主超时(15 秒无响应)',
|
|
113
|
+
'export.failed': '导出失败:宿主返回了无法识别的数据',
|
|
114
|
+
'import.notJson': '导入文件不是有效的 JSON({detail})',
|
|
115
|
+
'import.tooLarge': '文件约 {size} MB,远超本插件可导入的规模(约 {limit} MB 以内)。请确认选择的是「导出」生成的 JSON 文件。',
|
|
116
|
+
'editor.new': '新增指令',
|
|
117
|
+
'editor.edit': '编辑指令',
|
|
118
|
+
'editor.title': '标题',
|
|
119
|
+
'editor.titlePlaceholder': '例如:回答语言',
|
|
120
|
+
'editor.content': '指令内容',
|
|
121
|
+
'editor.contentPlaceholder': '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
|
|
122
|
+
'editor.mode': '生效方式',
|
|
123
|
+
'editor.modeAlways': '始终生效(无条件遵守)',
|
|
124
|
+
'editor.modeAuto': '按需生效(按相关性自动判断)',
|
|
125
|
+
'editor.priority': '优先级',
|
|
126
|
+
'priority.high': '高',
|
|
127
|
+
'priority.normal': '普通',
|
|
128
|
+
'priority.low': '低',
|
|
129
|
+
'editor.when': '适用场景(可选,帮助模型判断何时生效)',
|
|
130
|
+
'editor.whenPlaceholder': '例如:编写或修改代码时',
|
|
131
|
+
'editor.save': '保存',
|
|
132
|
+
'editor.saving': '保存中…',
|
|
133
|
+
'editor.cancel': '取消',
|
|
134
|
+
'editor.hint': '保存后对下一轮对话立即生效',
|
|
135
|
+
'editor.needTitleOrContent': '请至少填写标题或指令内容',
|
|
136
|
+
'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
|
|
137
|
+
'body.expand': '展开全部',
|
|
138
|
+
'body.collapse': '收起',
|
|
139
|
+
'budget.label': '注入字数上限(字符)',
|
|
140
|
+
'budget.unitHint': '单位是字符,不是 token',
|
|
141
|
+
'budget.rangeError': '上限需在 {min} ~ {max} 之间',
|
|
142
|
+
'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
|
|
143
|
+
'rules.label': '生效规则',
|
|
144
|
+
'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
|
|
145
|
+
'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
|
|
146
|
+
'search.matchCount': '匹配 {shown} / {total} 条',
|
|
147
|
+
'action.new': '+ 新增指令',
|
|
148
|
+
'action.reload': '重新载入',
|
|
149
|
+
'action.export': '导出',
|
|
150
|
+
'action.import': '导入',
|
|
151
|
+
'action.showPreview': '查看实际注入内容',
|
|
152
|
+
'action.hidePreview': '收起注入内容',
|
|
153
|
+
'stats.count': '共 {total} 条,生效 {active} 条',
|
|
154
|
+
'storage.file': '存储文件:{path}',
|
|
155
|
+
'storage.noFile': '(未写入磁盘)',
|
|
156
|
+
'storage.savedAt': '最近保存:{time}',
|
|
157
|
+
'storage.notSaved': '尚未保存到磁盘',
|
|
158
|
+
'storage.migrated': '已从旧位置迁移:{path}(原文件保留为 *.migrated)',
|
|
159
|
+
'preview.label': '当前注入到每轮系统提示词的内容',
|
|
160
|
+
'preview.empty': '(当前没有生效中的指令)',
|
|
161
|
+
'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
|
|
162
|
+
'list.noMatch': '没有匹配“{query}”的指令。',
|
|
163
|
+
'tag.always': '始终',
|
|
164
|
+
'tag.auto': '按需',
|
|
165
|
+
'entry.enabled': '启用',
|
|
166
|
+
'entry.edit': '编辑',
|
|
167
|
+
'entry.delete': '删除',
|
|
168
|
+
'entry.untitled': '未命名指令',
|
|
169
|
+
'entry.confirmDelete': '确定删除「{title}」?删除后无法恢复。',
|
|
170
|
+
'entry.when': '适用场景:{when}',
|
|
171
|
+
'entry.updatedAt': '更新于 {time}',
|
|
172
|
+
'undo.deleted': '已删除「{title}」',
|
|
173
|
+
'undo.restore': '撤销删除',
|
|
174
|
+
'undo.dismiss': '知道了',
|
|
175
|
+
'undo.restored': '已恢复刚删除的指令',
|
|
176
|
+
},
|
|
177
|
+
en: {
|
|
178
|
+
'section.label': 'Instruction Memory',
|
|
179
|
+
'panel.title': 'Instruction Memory',
|
|
180
|
+
'panel.subtitle': 'Saved instructions are written into the system prompt and apply to every conversation, answer, and task that follows.',
|
|
181
|
+
'panel.loading': 'Loading instruction memory…',
|
|
182
|
+
'panel.masterSwitch': 'Master switch',
|
|
183
|
+
'status.mounted': 'Mounted into the system prompt — effective for every turn that follows ({chars} chars)',
|
|
184
|
+
'status.notMountedEmpty': 'Not mounted: no enabled instructions',
|
|
185
|
+
'status.notMounted': 'Not mounted',
|
|
186
|
+
'err.unrecognized': 'The host returned data this panel cannot recognize',
|
|
187
|
+
'err.http': 'Host returned HTTP {status}',
|
|
188
|
+
'err.httpDetail': 'Host returned HTTP {status}: {detail}',
|
|
189
|
+
'err.timeout': 'Host request timed out (no response for 15 s)',
|
|
190
|
+
'export.failed': 'Export failed: the host returned unrecognized data',
|
|
191
|
+
'import.notJson': 'The import file is not valid JSON ({detail})',
|
|
192
|
+
'import.tooLarge': 'This file is about {size} MB, far beyond what this plugin can import (keep it within about {limit} MB). Make sure you selected a JSON file produced by Export.',
|
|
193
|
+
'editor.new': 'New instruction',
|
|
194
|
+
'editor.edit': 'Edit instruction',
|
|
195
|
+
'editor.title': 'Title',
|
|
196
|
+
'editor.titlePlaceholder': 'e.g. Response language',
|
|
197
|
+
'editor.content': 'Instruction content',
|
|
198
|
+
'editor.contentPlaceholder': 'e.g. Always answer in Simplified Chinese; ask before acting when the requirement is unclear.',
|
|
199
|
+
'editor.mode': 'Effect mode',
|
|
200
|
+
'editor.modeAlways': 'Always (obeyed unconditionally)',
|
|
201
|
+
'editor.modeAuto': 'On demand (the model judges by relevance)',
|
|
202
|
+
'editor.priority': 'Priority',
|
|
203
|
+
'priority.high': 'High',
|
|
204
|
+
'priority.normal': 'Normal',
|
|
205
|
+
'priority.low': 'Low',
|
|
206
|
+
'editor.when': 'Applicable scenarios (optional; helps the model decide when it applies)',
|
|
207
|
+
'editor.whenPlaceholder': 'e.g. When writing or modifying code',
|
|
208
|
+
'editor.save': 'Save',
|
|
209
|
+
'editor.saving': 'Saving…',
|
|
210
|
+
'editor.cancel': 'Cancel',
|
|
211
|
+
'editor.hint': 'Takes effect for the next conversation turn once saved',
|
|
212
|
+
'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
|
|
213
|
+
'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
|
|
214
|
+
'body.expand': 'Show all',
|
|
215
|
+
'body.collapse': 'Show less',
|
|
216
|
+
'budget.label': 'Injection size limit (characters)',
|
|
217
|
+
'budget.unitHint': 'The unit is characters, not tokens',
|
|
218
|
+
'budget.rangeError': 'The limit must be between {min} and {max}',
|
|
219
|
+
'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.',
|
|
220
|
+
'rules.label': 'How instructions apply',
|
|
221
|
+
'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
|
+
'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
|
|
223
|
+
'search.matchCount': '{shown} of {total} match',
|
|
224
|
+
'action.new': '+ New instruction',
|
|
225
|
+
'action.reload': 'Reload',
|
|
226
|
+
'action.export': 'Export',
|
|
227
|
+
'action.import': 'Import',
|
|
228
|
+
'action.showPreview': 'Show what is actually injected',
|
|
229
|
+
'action.hidePreview': 'Hide injected content',
|
|
230
|
+
'stats.count': '{total} in total, {active} active',
|
|
231
|
+
'storage.file': 'Store file: {path}',
|
|
232
|
+
'storage.noFile': '(not yet written to disk)',
|
|
233
|
+
'storage.savedAt': 'Last saved: {time}',
|
|
234
|
+
'storage.notSaved': 'Not yet saved to disk',
|
|
235
|
+
'storage.migrated': 'Migrated from the old location: {path} (the original is kept as *.migrated)',
|
|
236
|
+
'preview.label': 'What the system prompt receives on every turn',
|
|
237
|
+
'preview.empty': '(no active instructions right now)',
|
|
238
|
+
'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
|
|
239
|
+
'list.noMatch': 'No instructions match “{query}”.',
|
|
240
|
+
'tag.always': 'Always',
|
|
241
|
+
'tag.auto': 'On demand',
|
|
242
|
+
'entry.enabled': 'Enabled',
|
|
243
|
+
'entry.edit': 'Edit',
|
|
244
|
+
'entry.delete': 'Delete',
|
|
245
|
+
'entry.untitled': 'Untitled instruction',
|
|
246
|
+
'entry.confirmDelete': 'Delete “{title}”? This cannot be undone.',
|
|
247
|
+
'entry.when': 'Applies when: {when}',
|
|
248
|
+
'entry.updatedAt': 'Updated {time}',
|
|
249
|
+
'undo.deleted': 'Deleted “{title}”',
|
|
250
|
+
'undo.restore': 'Undo delete',
|
|
251
|
+
'undo.dismiss': 'Dismiss',
|
|
252
|
+
'undo.restored': 'The deleted instruction was restored',
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Same placeholder semantics as the official LocaleService.interpolate. */
|
|
257
|
+
function interpolate(template, params) {
|
|
258
|
+
if (!params) return template
|
|
259
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A translator bound to one fixed locale, straight from the built-in
|
|
264
|
+
* dictionaries. Lookup chain: the locale's own entry -> the zh fallback ->
|
|
265
|
+
* the key itself (a missing translation stays visible as the key, fail
|
|
266
|
+
* loud like the official registry, never a blank).
|
|
267
|
+
*/
|
|
268
|
+
function dictionaryT(locale) {
|
|
269
|
+
const dict = STRINGS[locale] !== undefined ? STRINGS[locale] : STRINGS.zh
|
|
270
|
+
return (key, params) => {
|
|
271
|
+
const template = dict[key] !== undefined ? dict[key] : STRINGS.zh[key]
|
|
272
|
+
if (template === undefined) return key
|
|
273
|
+
return interpolate(template, params)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The browser's own language, matched on the primary subtag exactly like
|
|
279
|
+
* the official detectBrowserLocale (`zh-Hans-CN` -> zh, `en-GB` -> en).
|
|
280
|
+
* `window` is the browser test, not `navigator`: Node exposes a global
|
|
281
|
+
* navigator reporting the machine's language, which must not decide the
|
|
282
|
+
* locale for non-browser runs. Used only when the official locale service
|
|
283
|
+
* is unavailable; resolved once (there is no UI to switch it mid-session).
|
|
284
|
+
*/
|
|
285
|
+
function detectFallbackLocale() {
|
|
286
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') return 'zh'
|
|
287
|
+
const tags = []
|
|
288
|
+
if (Array.isArray(navigator.languages)) tags.push(...navigator.languages)
|
|
289
|
+
if (navigator.language) tags.push(navigator.language)
|
|
290
|
+
for (const tag of tags) {
|
|
291
|
+
const primary = String(tag).toLowerCase().split('-')[0]
|
|
292
|
+
if (STRINGS[primary] !== undefined) return primary
|
|
293
|
+
}
|
|
294
|
+
return 'zh'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The runtime context of the running apply(); kept for lazy service lookups
|
|
298
|
+
// because the locale service may publish before OR after this plugin.
|
|
299
|
+
let runtimeCtx = null
|
|
300
|
+
// The official LocaleService once adopted; null = built-in fallback.
|
|
301
|
+
let adoptedService = null
|
|
302
|
+
let serviceBoundT = null
|
|
303
|
+
let fallbackT = null
|
|
304
|
+
const localeListeners = new Set()
|
|
305
|
+
|
|
306
|
+
function notifyLocaleListeners() {
|
|
307
|
+
for (const listener of [...localeListeners]) {
|
|
308
|
+
try { listener() } catch (err) {
|
|
309
|
+
// One crashed listener must not break the notification for the rest.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Subscribe to locale switches and late service adoptions. */
|
|
315
|
+
function onLocaleChange(listener) {
|
|
316
|
+
localeListeners.add(listener)
|
|
317
|
+
return () => { localeListeners.delete(listener) }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The official service if it is published right now, else null. */
|
|
321
|
+
function peekLocaleService(ctx) {
|
|
322
|
+
const owner = ctx !== undefined && ctx !== null ? ctx : runtimeCtx
|
|
323
|
+
if (owner === null) return null
|
|
324
|
+
let svc = null
|
|
325
|
+
try {
|
|
326
|
+
if (typeof owner.get === 'function') svc = owner.get('locale') || null
|
|
327
|
+
if (svc === null && owner.locale !== undefined) svc = owner.locale
|
|
328
|
+
} catch (err) {
|
|
329
|
+
svc = null
|
|
330
|
+
}
|
|
331
|
+
return svc
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Start routing t() through the official LocaleService: register our
|
|
336
|
+
* dictionaries (single occupant per namespace; a re-adoption of the same
|
|
337
|
+
* instance rethrows harmlessly because the first dictionaries are
|
|
338
|
+
* byte-identical), subscribe to its changes, and re-render every mounted
|
|
339
|
+
* panel. Idempotent; shape-checked so a hostile or alien 'locale' service
|
|
340
|
+
* can never break the panel — the built-in fallback just stays in charge.
|
|
341
|
+
*/
|
|
342
|
+
function adoptLocaleService(svc) {
|
|
343
|
+
if (svc === null || typeof svc !== 'object') return false
|
|
344
|
+
if (typeof svc.register !== 'function' || typeof svc.bind !== 'function' || typeof svc.subscribe !== 'function') return false
|
|
345
|
+
if (adoptedService === svc) return true
|
|
346
|
+
try {
|
|
347
|
+
svc.register(NS, { zh: STRINGS.zh, en: STRINGS.en })
|
|
348
|
+
} catch (err) {
|
|
349
|
+
// The namespace is already populated — by an earlier fiber life of
|
|
350
|
+
// this same plugin with identical dictionaries. Binding still works.
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
svc.subscribe(notifyLocaleListeners)
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// A service without working subscriptions still translates; the panel
|
|
356
|
+
// then only refreshes on remount instead of on every switch.
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
serviceBoundT = svc.bind(NS)
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return false
|
|
362
|
+
}
|
|
363
|
+
adoptedService = svc
|
|
364
|
+
notifyLocaleListeners()
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The panel's translator. Reads the ACTIVE locale at call time, so a
|
|
370
|
+
* re-render is all it takes to follow a language switch.
|
|
371
|
+
*/
|
|
372
|
+
function t(key, params) {
|
|
373
|
+
if (serviceBoundT !== null) return serviceBoundT(key, params)
|
|
374
|
+
if (fallbackT === null) fallbackT = dictionaryT(detectFallbackLocale())
|
|
375
|
+
return fallbackT(key, params)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ---------------- data helpers ---------------- */
|
|
379
|
+
|
|
80
380
|
function cloneEntry(entry) {
|
|
81
381
|
return {
|
|
82
382
|
id: entry.id,
|
|
@@ -94,6 +394,35 @@ window.__ModuleLoader__.load({
|
|
|
94
394
|
return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, updatedAt: 0 }
|
|
95
395
|
}
|
|
96
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Whether the editor draft carries edits the user would lose by cancelling.
|
|
399
|
+
*
|
|
400
|
+
* Cancelling used to discard the draft instantly — one stray click on 取消
|
|
401
|
+
* and a half-typed 6,000-character instruction was gone. Now a blank new
|
|
402
|
+
* draft, or a copy of an existing entry with nothing touched, still cancels
|
|
403
|
+
* silently; anything else asks first. `updatedAt` is excluded because the
|
|
404
|
+
* Host stamps it on save — it is never user-typed. If the draft's base
|
|
405
|
+
* entry no longer exists (deleted from another window), treat it as dirty:
|
|
406
|
+
* confirming is always the safe direction.
|
|
407
|
+
*/
|
|
408
|
+
function draftIsDirty(draft, entries) {
|
|
409
|
+
const fields = ['title', 'content', 'mode', 'when', 'priority', 'enabled']
|
|
410
|
+
const base = draft.id === '' ? blankDraft() : (entries || []).find((entry) => entry.id === draft.id)
|
|
411
|
+
if (base === undefined) return true
|
|
412
|
+
return fields.some((field) => String(draft[field]) !== String(base[field]))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Ask before throwing the draft away. Falls back to "yes" when no dialog
|
|
417
|
+
* is available (a bare webview without window.confirm): blocking the
|
|
418
|
+
* cancel button entirely would be worse than the old instant discard.
|
|
419
|
+
*/
|
|
420
|
+
function confirmDiscard() {
|
|
421
|
+
return typeof window !== 'undefined' && typeof window.confirm === 'function'
|
|
422
|
+
? window.confirm(t('editor.confirmDiscard'))
|
|
423
|
+
: true
|
|
424
|
+
}
|
|
425
|
+
|
|
97
426
|
/**
|
|
98
427
|
* Accept both response shapes the Host can produce: the
|
|
99
428
|
* `{ ok, message, snapshot }` envelope used by the mutating methods, and a
|
|
@@ -107,9 +436,67 @@ window.__ModuleLoader__.load({
|
|
|
107
436
|
snapshot,
|
|
108
437
|
ok: result.ok !== false,
|
|
109
438
|
message: typeof result.message === 'string' ? result.message : '',
|
|
439
|
+
conflict: result.conflict === true,
|
|
110
440
|
}
|
|
111
441
|
}
|
|
112
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Whether two snapshots carry identical content.
|
|
445
|
+
*
|
|
446
|
+
* The Host pushes every persisted change to every open panel, so the window
|
|
447
|
+
* that issued the command receives an SSE frame matching the POST response
|
|
448
|
+
* it already absorbed. Re-absorbing would not corrupt anything, but it
|
|
449
|
+
* would replace the response's message ('已保存…' / a truncation notice)
|
|
450
|
+
* with the frame's empty one — the success feedback would blink out a tick
|
|
451
|
+
* after appearing. An identical frame is therefore dropped instead.
|
|
452
|
+
*/
|
|
453
|
+
function sameSnapshot(a, b) {
|
|
454
|
+
return a !== null && typeof a === 'object'
|
|
455
|
+
&& b !== null && typeof b === 'object'
|
|
456
|
+
&& JSON.stringify(a) === JSON.stringify(b)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Cheap import gate, run on the File's byte size BEFORE the file is read.
|
|
461
|
+
*
|
|
462
|
+
* Picking the wrong file (a log, a dataset, a lockfile — tens or hundreds
|
|
463
|
+
* of MB) used to mean the whole thing was read into memory, parsed with
|
|
464
|
+
* the UI frozen, uploaded, and only then refused with 413. This refuses
|
|
465
|
+
* first, with numbers in the message.
|
|
466
|
+
*
|
|
467
|
+
* The ceiling is deliberately loose, because `maxBodyChars` counts the
|
|
468
|
+
* compact request BODY in characters while `file.size` counts pretty-
|
|
469
|
+
* printed FILE bytes: UTF-8 CJK is up to 3 bytes per character and an
|
|
470
|
+
* export is written with 2-space indentation. A maximal store this plugin
|
|
471
|
+
* exports is ≈4 MB of file; the ×4 guard (≈10 MB) can never reject it.
|
|
472
|
+
* The cost of the slack is only that a slightly-too-big wrong file still
|
|
473
|
+
* takes the slow 413 path. An older Host that publishes no
|
|
474
|
+
* `maxBodyChars` disables the gate (the Host's own 413 still applies).
|
|
475
|
+
*/
|
|
476
|
+
function importSizeVerdict(byteSize, maxBodyChars) {
|
|
477
|
+
if (typeof byteSize !== 'number' || !Number.isFinite(byteSize) || byteSize <= 0) return { ok: true }
|
|
478
|
+
if (typeof maxBodyChars !== 'number' || !(maxBodyChars > 0)) return { ok: true }
|
|
479
|
+
const ceilingBytes = maxBodyChars * 4
|
|
480
|
+
if (byteSize <= ceilingBytes) return { ok: true }
|
|
481
|
+
const mb = (n) => Math.max(1, Math.round(n / 1048576))
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
message: t('import.tooLarge', { size: mb(byteSize), limit: mb(ceilingBytes) }),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* What the budget input should show after a snapshot arrives.
|
|
490
|
+
*
|
|
491
|
+
* A push from another window used to reset budgetText unconditionally
|
|
492
|
+
* inside absorb(), so editing the injection-size field while any other
|
|
493
|
+
* panel saved wiped the half-typed number. While the input is focused the
|
|
494
|
+
* user's draft wins; the committed value re-syncs on blur.
|
|
495
|
+
*/
|
|
496
|
+
function nextBudgetText(currentText, incoming, focused) {
|
|
497
|
+
return focused ? currentText : String(incoming)
|
|
498
|
+
}
|
|
499
|
+
|
|
113
500
|
async function callHost(method, args) {
|
|
114
501
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
115
502
|
// after a generous timeout instead.
|
|
@@ -133,11 +520,13 @@ window.__ModuleLoader__.load({
|
|
|
133
520
|
} catch (err) {
|
|
134
521
|
// A non-JSON error body leaves the status code as the only clue.
|
|
135
522
|
}
|
|
136
|
-
throw new Error(
|
|
523
|
+
throw new Error(detail === ''
|
|
524
|
+
? t('err.http', { status: response.status })
|
|
525
|
+
: t('err.httpDetail', { status: response.status, detail }))
|
|
137
526
|
}
|
|
138
527
|
return await response.json()
|
|
139
528
|
} catch (err) {
|
|
140
|
-
if (controller !== null && controller.signal.aborted) throw new Error('
|
|
529
|
+
if (controller !== null && controller.signal.aborted) throw new Error(t('err.timeout'))
|
|
141
530
|
throw err
|
|
142
531
|
} finally {
|
|
143
532
|
if (timer !== null) clearTimeout(timer)
|
|
@@ -148,63 +537,63 @@ window.__ModuleLoader__.load({
|
|
|
148
537
|
const draft = props.draft
|
|
149
538
|
const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
|
|
150
539
|
const children = [
|
|
151
|
-
h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? '
|
|
540
|
+
h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
|
|
152
541
|
h('div', { key: 't' },
|
|
153
|
-
h('label', { className: 'im-label' }, '
|
|
542
|
+
h('label', { className: 'im-label' }, t('editor.title')),
|
|
154
543
|
h('input', {
|
|
155
544
|
className: 'im-input',
|
|
156
545
|
value: draft.title,
|
|
157
546
|
maxLength: 120,
|
|
158
|
-
placeholder: '
|
|
547
|
+
placeholder: t('editor.titlePlaceholder'),
|
|
159
548
|
onChange: (ev) => set({ title: ev.target.value }),
|
|
160
549
|
})),
|
|
161
550
|
h('div', { key: 'c' },
|
|
162
|
-
h('label', { className: 'im-label' }, '
|
|
551
|
+
h('label', { className: 'im-label' }, t('editor.content')),
|
|
163
552
|
h('textarea', {
|
|
164
553
|
className: 'im-textarea',
|
|
165
554
|
value: draft.content,
|
|
166
555
|
maxLength: 6000,
|
|
167
|
-
placeholder: '
|
|
556
|
+
placeholder: t('editor.contentPlaceholder'),
|
|
168
557
|
onChange: (ev) => set({ content: ev.target.value }),
|
|
169
558
|
})),
|
|
170
559
|
h('div', { className: 'im-grid2', key: 'g' },
|
|
171
560
|
h('div', null,
|
|
172
|
-
h('label', { className: 'im-label' }, '
|
|
561
|
+
h('label', { className: 'im-label' }, t('editor.mode')),
|
|
173
562
|
h('select', {
|
|
174
563
|
className: 'im-select',
|
|
175
564
|
value: draft.mode,
|
|
176
565
|
onChange: (ev) => set({ mode: ev.target.value }),
|
|
177
566
|
},
|
|
178
|
-
h('option', { value: 'always' }, '
|
|
179
|
-
h('option', { value: 'auto' }, '
|
|
567
|
+
h('option', { value: 'always' }, t('editor.modeAlways')),
|
|
568
|
+
h('option', { value: 'auto' }, t('editor.modeAuto')))),
|
|
180
569
|
h('div', null,
|
|
181
|
-
h('label', { className: 'im-label' }, '
|
|
570
|
+
h('label', { className: 'im-label' }, t('editor.priority')),
|
|
182
571
|
h('select', {
|
|
183
572
|
className: 'im-select',
|
|
184
573
|
value: String(draft.priority),
|
|
185
574
|
onChange: (ev) => set({ priority: Number(ev.target.value) }),
|
|
186
575
|
},
|
|
187
|
-
h('option', { value: '2' }, '
|
|
188
|
-
h('option', { value: '1' }, '
|
|
189
|
-
h('option', { value: '0' }, '
|
|
576
|
+
h('option', { value: '2' }, t('priority.high')),
|
|
577
|
+
h('option', { value: '1' }, t('priority.normal')),
|
|
578
|
+
h('option', { value: '0' }, t('priority.low'))))),
|
|
190
579
|
]
|
|
191
580
|
if (draft.mode === 'auto') {
|
|
192
581
|
children.push(h('div', { key: 'w' },
|
|
193
|
-
h('label', { className: 'im-label' }, '
|
|
582
|
+
h('label', { className: 'im-label' }, t('editor.when')),
|
|
194
583
|
h('input', {
|
|
195
584
|
className: 'im-input',
|
|
196
585
|
value: draft.when,
|
|
197
586
|
maxLength: 200,
|
|
198
|
-
placeholder: '
|
|
587
|
+
placeholder: t('editor.whenPlaceholder'),
|
|
199
588
|
onChange: (ev) => set({ when: ev.target.value }),
|
|
200
589
|
})))
|
|
201
590
|
}
|
|
202
591
|
children.push(h('div', { className: 'im-row-wrap', key: 'a' },
|
|
203
592
|
h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
|
|
204
|
-
props.busy ? '
|
|
205
|
-
h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, '
|
|
593
|
+
props.busy ? t('editor.saving') : t('editor.save')),
|
|
594
|
+
h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, t('editor.cancel')),
|
|
206
595
|
h('span', { className: 'im-spacer' }),
|
|
207
|
-
h('span', { className: 'im-meta' }, '
|
|
596
|
+
h('span', { className: 'im-meta' }, t('editor.hint'))))
|
|
208
597
|
return h('div', { className: 'im-card' }, children)
|
|
209
598
|
}
|
|
210
599
|
|
|
@@ -223,9 +612,12 @@ window.__ModuleLoader__.load({
|
|
|
223
612
|
}, [props.text])
|
|
224
613
|
return h('div', null,
|
|
225
614
|
h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
|
|
226
|
-
overflowing ? h('div', { className: 'im-more', onClick: () => setOpen(!open) }, open ? '
|
|
615
|
+
overflowing ? h('div', { className: 'im-more', onClick: () => setOpen(!open) }, open ? t('body.collapse') : t('body.expand')) : null)
|
|
227
616
|
}
|
|
228
617
|
|
|
618
|
+
/** How long the undo bar keeps its offer alive after a confirmed delete. */
|
|
619
|
+
const UNDO_TTL_MS = 10000
|
|
620
|
+
|
|
229
621
|
function InstructionPanel() {
|
|
230
622
|
const [view, setView] = React.useState(null)
|
|
231
623
|
const [error, setError] = React.useState('')
|
|
@@ -233,20 +625,74 @@ window.__ModuleLoader__.load({
|
|
|
233
625
|
const [busy, setBusy] = React.useState(false)
|
|
234
626
|
const [draft, setDraft] = React.useState(null)
|
|
235
627
|
const [budgetText, setBudgetText] = React.useState('')
|
|
628
|
+
// Latest live value of the draft, because the SSE onmessage closure below
|
|
629
|
+
// is created once and must not read a stale first-render budgetText.
|
|
630
|
+
const budgetTextRef = React.useRef('')
|
|
631
|
+
budgetTextRef.current = budgetText
|
|
236
632
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
237
633
|
const [query, setQuery] = React.useState('')
|
|
634
|
+
const [undo, setUndo] = React.useState(null)
|
|
238
635
|
const importInputRef = React.useRef(null)
|
|
636
|
+
// Whether the budget input is being edited right now. A live SSE push
|
|
637
|
+
// from another window must not replace the number under the cursor;
|
|
638
|
+
// the ref (not state) is read inside absorb so no re-render is needed.
|
|
639
|
+
const budgetFocusedRef = React.useRef(false)
|
|
640
|
+
// The latest snapshot this panel has absorbed. The SSE effect runs once
|
|
641
|
+
// (empty deps), so it cannot read the `view` state of a later render —
|
|
642
|
+
// the ref is how its frames are compared against what is on screen.
|
|
643
|
+
const viewRef = React.useRef(null)
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Re-render on locale switches. t() reads the active locale at call
|
|
647
|
+
* time, so a fresh render re-translates everything; the same tick also
|
|
648
|
+
* covers a locale service adopted after this panel mounted (a runtime
|
|
649
|
+
* without ctx.inject, or one that publishes the service late).
|
|
650
|
+
*/
|
|
651
|
+
const [, bumpLocale] = React.useState(0)
|
|
652
|
+
React.useEffect(() => {
|
|
653
|
+
const svc = peekLocaleService()
|
|
654
|
+
if (svc !== null) adoptLocaleService(svc)
|
|
655
|
+
return onLocaleChange(() => bumpLocale((v) => v + 1))
|
|
656
|
+
}, [])
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The undo bar is an offer with a shelf life: it lapses on its own
|
|
660
|
+
* UNDO_TTL_MS after the delete, so a stale offer never outlives the
|
|
661
|
+
* deletion it refers to. The deadline is stamped into the bar state, not
|
|
662
|
+
* computed here, so the countdown starts at delete time and survives
|
|
663
|
+
* re-renders (including the SSE absorb of our own delete).
|
|
664
|
+
*/
|
|
665
|
+
React.useEffect(() => {
|
|
666
|
+
if (undo === null) return undefined
|
|
667
|
+
const timer = setTimeout(() => setUndo(null), Math.max(0, undo.expires - Date.now()))
|
|
668
|
+
return () => clearTimeout(timer)
|
|
669
|
+
}, [undo])
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The rev of the snapshot this panel is currently rendering. Sent back as
|
|
673
|
+
* baseRev on every mutation so the Host can refuse a write built on a
|
|
674
|
+
* stale view (another window or process changed the store in between)
|
|
675
|
+
* instead of letting it overwrite blindly. Undefined against an older
|
|
676
|
+
* Host whose snapshot carries no rev — JSON.stringify drops the key, so
|
|
677
|
+
* the request stays byte-identical to what that Host expects.
|
|
678
|
+
*/
|
|
679
|
+
const revOf = () => (view !== null && view.data && typeof view.data.rev === 'number' ? view.data.rev : undefined)
|
|
239
680
|
|
|
240
681
|
function absorb(result) {
|
|
241
682
|
const normalized = normalizeResult(result)
|
|
242
683
|
if (normalized === null) {
|
|
243
684
|
// Never fail silently: the previous version returned here, leaving the
|
|
244
685
|
// panel on its loading state forever with no error to act on.
|
|
245
|
-
setError('
|
|
686
|
+
setError(t('err.unrecognized'))
|
|
246
687
|
return
|
|
247
688
|
}
|
|
248
689
|
setView(normalized.snapshot)
|
|
249
|
-
|
|
690
|
+
viewRef.current = normalized.snapshot
|
|
691
|
+
// Keep the user's in-progress budget edit when a push arrives while
|
|
692
|
+
// the input is focused; re-sync only once editing is not active. The
|
|
693
|
+
// ref is the live draft — absorb may run from the SSE closure created
|
|
694
|
+
// on the first render.
|
|
695
|
+
setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
|
|
250
696
|
if (!normalized.ok && normalized.message) {
|
|
251
697
|
setError(normalized.message)
|
|
252
698
|
setNotice('')
|
|
@@ -274,7 +720,7 @@ window.__ModuleLoader__.load({
|
|
|
274
720
|
try {
|
|
275
721
|
const result = await callHost('export-data', null)
|
|
276
722
|
if (result === null || typeof result !== 'object' || result.export === undefined || result.export === null) {
|
|
277
|
-
setError('
|
|
723
|
+
setError(t('export.failed'))
|
|
278
724
|
return
|
|
279
725
|
}
|
|
280
726
|
const blob = new Blob([JSON.stringify(result.export, null, 2) + '\n'], { type: 'application/json' })
|
|
@@ -293,6 +739,14 @@ window.__ModuleLoader__.load({
|
|
|
293
739
|
|
|
294
740
|
async function handleImportFile(file) {
|
|
295
741
|
try {
|
|
742
|
+
const verdict = importSizeVerdict(
|
|
743
|
+
file && typeof file.size === 'number' ? file.size : NaN,
|
|
744
|
+
view !== null && view.limits ? view.limits.maxBodyChars : undefined,
|
|
745
|
+
)
|
|
746
|
+
if (!verdict.ok) {
|
|
747
|
+
setError(verdict.message)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
296
750
|
const text = typeof file.text === 'function'
|
|
297
751
|
? await file.text()
|
|
298
752
|
: await new Promise((resolve, reject) => {
|
|
@@ -305,10 +759,10 @@ window.__ModuleLoader__.load({
|
|
|
305
759
|
try {
|
|
306
760
|
payload = JSON.parse(text)
|
|
307
761
|
} catch (err) {
|
|
308
|
-
setError('
|
|
762
|
+
setError(t('import.notJson', { detail: String(err && err.message ? err.message : err) }))
|
|
309
763
|
return
|
|
310
764
|
}
|
|
311
|
-
await send('import-data', { payload })
|
|
765
|
+
await send('import-data', { payload, baseRev: revOf() })
|
|
312
766
|
} catch (err) {
|
|
313
767
|
setError(String(err && err.message ? err.message : err))
|
|
314
768
|
}
|
|
@@ -323,9 +777,34 @@ window.__ModuleLoader__.load({
|
|
|
323
777
|
return () => { alive = false }
|
|
324
778
|
}, [])
|
|
325
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Live updates: the Host streams a fresh snapshot over SSE whenever the
|
|
782
|
+
* store changes underneath this panel — another window on the same host,
|
|
783
|
+
* another process sharing the file, or a hand-edited memory.json. Our
|
|
784
|
+
* own writes come back the same way; sameSnapshot drops that echo so a
|
|
785
|
+
* just-shown success notice survives. EventSource reconnects by itself
|
|
786
|
+
* (the Host asks for a 5s retry), and without it the panel still works
|
|
787
|
+
* off 重新载入 — a dead stream degrades to manual refresh, never to an
|
|
788
|
+
* error the user has to dismiss.
|
|
789
|
+
*/
|
|
790
|
+
React.useEffect(() => {
|
|
791
|
+
if (typeof EventSource !== 'function') return
|
|
792
|
+
const source = new EventSource(API)
|
|
793
|
+
source.onmessage = (ev) => {
|
|
794
|
+
try {
|
|
795
|
+
const pushed = JSON.parse(ev.data)
|
|
796
|
+
if (sameSnapshot(pushed, viewRef.current)) return
|
|
797
|
+
absorb(pushed)
|
|
798
|
+
} catch {
|
|
799
|
+
// A malformed frame is dropped; the next frame, or 重新载入, catches up.
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return () => { source.close() }
|
|
803
|
+
}, [])
|
|
804
|
+
|
|
326
805
|
if (view === null) {
|
|
327
806
|
return h('div', { className: 'im-root' },
|
|
328
|
-
h('div', { className: 'im-empty' }, error === '' ? '
|
|
807
|
+
h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error))
|
|
329
808
|
}
|
|
330
809
|
|
|
331
810
|
if (draft !== null) {
|
|
@@ -334,13 +813,16 @@ window.__ModuleLoader__.load({
|
|
|
334
813
|
draft,
|
|
335
814
|
busy,
|
|
336
815
|
onChange: setDraft,
|
|
337
|
-
onCancel: () => {
|
|
816
|
+
onCancel: () => {
|
|
817
|
+
if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
|
|
818
|
+
setDraft(null); setError('')
|
|
819
|
+
},
|
|
338
820
|
onSave: () => {
|
|
339
821
|
if (draft.title.trim() === '' && draft.content.trim() === '') {
|
|
340
|
-
setError('
|
|
822
|
+
setError(t('editor.needTitleOrContent'))
|
|
341
823
|
return
|
|
342
824
|
}
|
|
343
|
-
send('save-entry', { entry: draft }).then((result) => {
|
|
825
|
+
send('save-entry', { entry: draft, baseRev: revOf() }).then((result) => {
|
|
344
826
|
if (result && result.ok === true) { setDraft(null); setError('') }
|
|
345
827
|
})
|
|
346
828
|
},
|
|
@@ -369,28 +851,28 @@ window.__ModuleLoader__.load({
|
|
|
369
851
|
const dotText = injection.error
|
|
370
852
|
? injection.error
|
|
371
853
|
: (injection.registered
|
|
372
|
-
? '
|
|
373
|
-
: (activeCount === 0 ? '
|
|
854
|
+
? t('status.mounted', { chars: injection.chars })
|
|
855
|
+
: (activeCount === 0 ? t('status.notMountedEmpty') : t('status.notMounted')))
|
|
374
856
|
|
|
375
857
|
const header = h('div', { className: 'im-card', key: 'head' },
|
|
376
858
|
h('div', { className: 'im-row' },
|
|
377
859
|
h('div', null,
|
|
378
|
-
h('h3', { className: 'im-h' }, '
|
|
379
|
-
h('p', { className: 'im-sub' }, '
|
|
860
|
+
h('h3', { className: 'im-h' }, t('panel.title')),
|
|
861
|
+
h('p', { className: 'im-sub' }, t('panel.subtitle'))),
|
|
380
862
|
h('label', { className: 'im-switch' },
|
|
381
863
|
h('input', {
|
|
382
864
|
type: 'checkbox',
|
|
383
865
|
checked: data.enabled === true,
|
|
384
866
|
disabled: busy,
|
|
385
|
-
onChange: () => send('set-options', { enabled: !data.enabled }),
|
|
867
|
+
onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
|
|
386
868
|
}),
|
|
387
|
-
'
|
|
869
|
+
t('panel.masterSwitch'))),
|
|
388
870
|
h('div', { className: 'im-status' },
|
|
389
871
|
h('span', { className: dotClass }),
|
|
390
872
|
h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
|
|
391
873
|
h('div', { className: 'im-grid2' },
|
|
392
874
|
h('div', null,
|
|
393
|
-
h('label', { className: 'im-label' }, '
|
|
875
|
+
h('label', { className: 'im-label' }, t('budget.label')),
|
|
394
876
|
h('input', {
|
|
395
877
|
className: 'im-input',
|
|
396
878
|
type: 'number',
|
|
@@ -399,37 +881,43 @@ window.__ModuleLoader__.load({
|
|
|
399
881
|
step: 100,
|
|
400
882
|
value: budgetText,
|
|
401
883
|
disabled: busy,
|
|
884
|
+
title: t('budget.unitHint'),
|
|
885
|
+
onFocus: () => { budgetFocusedRef.current = true },
|
|
402
886
|
onChange: (ev) => setBudgetText(ev.target.value),
|
|
403
887
|
onBlur: () => {
|
|
888
|
+
// Editing is over: later pushes may sync the field again.
|
|
889
|
+
budgetFocusedRef.current = false
|
|
404
890
|
const parsed = Number(budgetText)
|
|
405
891
|
if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
|
|
406
892
|
setBudgetText(String(data.budgetChars))
|
|
407
|
-
setError('
|
|
893
|
+
setError(t('budget.rangeError', { min: limits.minBudget, max: limits.maxBudget }))
|
|
408
894
|
return
|
|
409
895
|
}
|
|
410
|
-
send('set-options', { budgetChars: parsed })
|
|
896
|
+
send('set-options', { budgetChars: parsed, baseRev: revOf() })
|
|
411
897
|
},
|
|
412
|
-
})
|
|
898
|
+
}),
|
|
899
|
+
h('div', { className: 'im-meta' },
|
|
900
|
+
t('budget.note', { max: limits.maxBudget, tokens: Math.round(limits.maxBudget * 0.6) }))),
|
|
413
901
|
h('div', null,
|
|
414
|
-
h('label', { className: 'im-label' }, '
|
|
902
|
+
h('label', { className: 'im-label' }, t('rules.label')),
|
|
415
903
|
h('div', { className: 'im-meta' },
|
|
416
|
-
'
|
|
904
|
+
t('rules.note')))),
|
|
417
905
|
entries.length > 0 ? h('div', { key: 'search' },
|
|
418
906
|
h('input', {
|
|
419
907
|
className: 'im-input',
|
|
420
908
|
value: query,
|
|
421
|
-
placeholder: '
|
|
909
|
+
placeholder: t('search.placeholder', { count: entries.length }),
|
|
422
910
|
onChange: (ev) => setQuery(ev.target.value),
|
|
423
911
|
}),
|
|
424
|
-
query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, '
|
|
912
|
+
query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length })) : null) : null,
|
|
425
913
|
h('div', { className: 'im-row-wrap' },
|
|
426
914
|
h('button', {
|
|
427
915
|
className: 'im-btn im-btn-primary',
|
|
428
916
|
disabled: busy,
|
|
429
917
|
onClick: () => { setDraft(blankDraft()); setError('') },
|
|
430
|
-
}, '
|
|
431
|
-
h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, '
|
|
432
|
-
h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, '
|
|
918
|
+
}, t('action.new')),
|
|
919
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, t('action.reload')),
|
|
920
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, t('action.export')),
|
|
433
921
|
h('button', {
|
|
434
922
|
className: 'im-btn',
|
|
435
923
|
disabled: busy,
|
|
@@ -437,28 +925,46 @@ window.__ModuleLoader__.load({
|
|
|
437
925
|
const el = importInputRef.current
|
|
438
926
|
if (el !== null && el !== undefined) el.click()
|
|
439
927
|
},
|
|
440
|
-
}, '
|
|
928
|
+
}, t('action.import')),
|
|
441
929
|
h('button', {
|
|
442
930
|
className: 'im-btn',
|
|
443
931
|
disabled: busy,
|
|
444
932
|
onClick: () => setShowPreview(!showPreview),
|
|
445
|
-
}, showPreview ? '
|
|
933
|
+
}, showPreview ? t('action.hidePreview') : t('action.showPreview')),
|
|
446
934
|
h('span', { className: 'im-spacer' }),
|
|
447
|
-
h('span', { className: 'im-meta' }, '
|
|
935
|
+
h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
|
|
448
936
|
h('div', { className: 'im-divider' }),
|
|
449
|
-
h('div', { className: 'im-meta' }, '存储文件:' + (storage.path ? storage.path : '(未写入磁盘)')),
|
|
450
937
|
h('div', { className: 'im-meta' },
|
|
451
|
-
storage.
|
|
938
|
+
storage.path ? t('storage.file', { path: storage.path }) : t('storage.noFile')),
|
|
939
|
+
h('div', { className: 'im-meta' },
|
|
940
|
+
storage.savedAt ? t('storage.savedAt', { time: new Date(storage.savedAt).toLocaleString() }) : t('storage.notSaved')),
|
|
452
941
|
storage.migratedFrom
|
|
453
|
-
? h('div', { className: 'im-ok' }, '
|
|
942
|
+
? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
|
|
454
943
|
: null,
|
|
455
944
|
storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
|
|
456
945
|
error !== '' ? h('div', { className: 'im-err' }, error) : null,
|
|
457
946
|
error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
|
|
458
947
|
error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
|
|
948
|
+
undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
|
|
949
|
+
h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
|
|
950
|
+
h('button', { disabled: busy, onClick: () => {
|
|
951
|
+
const entry = cloneEntry(undo.entry)
|
|
952
|
+
send('save-entry', { entry, baseRev: revOf() }).then((result) => {
|
|
953
|
+
if (result === null || result.ok !== true) {
|
|
954
|
+
// A failed restore must not eat the offer: re-arm the bar so
|
|
955
|
+
// the user can retry. absorb() has already pulled in the
|
|
956
|
+
// winning snapshot, so the next click sends its fresh rev.
|
|
957
|
+
setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
setUndo(null)
|
|
961
|
+
setNotice(t('undo.restored'))
|
|
962
|
+
})
|
|
963
|
+
} }, t('undo.restore')),
|
|
964
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: () => setUndo(null) }, t('undo.dismiss'))) : null,
|
|
459
965
|
showPreview ? h('div', null,
|
|
460
|
-
h('label', { className: 'im-label' }, '
|
|
461
|
-
h('pre', { className: 'im-pre' }, view.preview === '' ? '
|
|
966
|
+
h('label', { className: 'im-label' }, t('preview.label')),
|
|
967
|
+
h('pre', { className: 'im-pre' }, view.preview === '' ? t('preview.empty') : view.preview)) : null,
|
|
462
968
|
h('input', {
|
|
463
969
|
key: 'import-input',
|
|
464
970
|
type: 'file',
|
|
@@ -477,10 +983,10 @@ window.__ModuleLoader__.load({
|
|
|
477
983
|
h('span', {
|
|
478
984
|
className: 'im-tag ' + (entry.mode === 'always' ? 'im-tag-always' : 'im-tag-auto'),
|
|
479
985
|
key: 'mode',
|
|
480
|
-
}, entry.mode === 'always' ? '
|
|
986
|
+
}, entry.mode === 'always' ? t('tag.always') : t('tag.auto')),
|
|
481
987
|
]
|
|
482
|
-
if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, '
|
|
483
|
-
else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, '
|
|
988
|
+
if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
|
|
989
|
+
else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
|
|
484
990
|
return h('div', {
|
|
485
991
|
className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
|
|
486
992
|
key: entry.id,
|
|
@@ -497,15 +1003,15 @@ window.__ModuleLoader__.load({
|
|
|
497
1003
|
onChange: () => {
|
|
498
1004
|
const next = cloneEntry(entry)
|
|
499
1005
|
next.enabled = !entry.enabled
|
|
500
|
-
send('save-entry', { entry: next })
|
|
1006
|
+
send('save-entry', { entry: next, baseRev: revOf() })
|
|
501
1007
|
},
|
|
502
1008
|
}),
|
|
503
|
-
'
|
|
1009
|
+
t('entry.enabled')),
|
|
504
1010
|
h('button', {
|
|
505
1011
|
className: 'im-btn',
|
|
506
1012
|
disabled: busy,
|
|
507
1013
|
onClick: () => { setDraft(cloneEntry(entry)); setError('') },
|
|
508
|
-
}, '
|
|
1014
|
+
}, t('entry.edit')),
|
|
509
1015
|
h('button', {
|
|
510
1016
|
className: 'im-btn im-btn-danger',
|
|
511
1017
|
disabled: busy,
|
|
@@ -513,27 +1019,44 @@ window.__ModuleLoader__.load({
|
|
|
513
1019
|
// confirm() is standard DOM, guarded anyway: if the embedded
|
|
514
1020
|
// shell omits it, deletion proceeds rather than breaking.
|
|
515
1021
|
if (typeof window.confirm === 'function'
|
|
516
|
-
&& !window.confirm(
|
|
517
|
-
|
|
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
|
+
})
|
|
518
1032
|
},
|
|
519
|
-
}, '
|
|
1033
|
+
}, t('entry.delete'))),
|
|
520
1034
|
h(EntryBody, { text: entry.content, key: 'body' }),
|
|
521
1035
|
entry.mode === 'auto' && entry.when !== ''
|
|
522
|
-
? h('div', { className: 'im-meta' }, '
|
|
1036
|
+
? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
|
|
1037
|
+
: null,
|
|
1038
|
+
// The Host stamps updatedAt on every save; entries imported from an
|
|
1039
|
+
// old export (or predating the field) carry 0/undefined and show
|
|
1040
|
+
// nothing rather than a 1970-01-01.
|
|
1041
|
+
entry.updatedAt > 0
|
|
1042
|
+
? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(entry.updatedAt).toLocaleString() }))
|
|
523
1043
|
: null)
|
|
524
1044
|
})
|
|
525
1045
|
|
|
526
1046
|
const list = entries.length === 0
|
|
527
1047
|
? h('div', { className: 'im-empty', key: 'empty' },
|
|
528
|
-
'
|
|
1048
|
+
t('list.empty'))
|
|
529
1049
|
: visible.length === 0
|
|
530
|
-
? h('div', { className: 'im-empty', key: 'no-match' }, '
|
|
1050
|
+
? h('div', { className: 'im-empty', key: 'no-match' }, t('list.noMatch', { query: query.trim() }))
|
|
531
1051
|
: h('div', { className: 'im-list', key: 'list' }, items)
|
|
532
1052
|
|
|
533
1053
|
return h('div', { className: 'im-root' }, header, list)
|
|
534
1054
|
}
|
|
535
1055
|
|
|
536
1056
|
function apply(ctx) {
|
|
1057
|
+
// Kept for lazy service lookups: the official locale service may be
|
|
1058
|
+
// published before or after this plugin activates.
|
|
1059
|
+
runtimeCtx = ctx
|
|
537
1060
|
// `slots` is a declared injection (see exports.inject below), so the
|
|
538
1061
|
// runtime holds this plugin until the slot service exists. The fallback
|
|
539
1062
|
// covers a runtime that resolves injections lazily instead.
|
|
@@ -552,18 +1075,53 @@ window.__ModuleLoader__.load({
|
|
|
552
1075
|
if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
|
|
553
1076
|
})
|
|
554
1077
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1078
|
+
// Locale adoption, path 1: a child fiber that runs as soon as (and as
|
|
1079
|
+
// long as) the official locale service exists — covering hosts where the
|
|
1080
|
+
// service publishes after this plugin activates. Hosts without the
|
|
1081
|
+
// service, or runtimes without ctx.inject, never run this; the built-in
|
|
1082
|
+
// fallback dictionaries keep the panel working either way.
|
|
1083
|
+
if (typeof ctx.inject === 'function') {
|
|
1084
|
+
try {
|
|
1085
|
+
ctx.inject(['locale'], (localeCtx) => {
|
|
1086
|
+
adoptLocaleService(peekLocaleService(localeCtx))
|
|
1087
|
+
})
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
// An exotic runtime: stay on the built-in fallback dictionaries.
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
ctx.effect(() => slots.inject('settings.section', () => {
|
|
1094
|
+
// Path 2: this factory runs when the settings shell activates the
|
|
1095
|
+
// slot; the shell itself declares the locale service as a dependency,
|
|
1096
|
+
// so on a locale-aware host the service is already published by now.
|
|
1097
|
+
const svc = peekLocaleService()
|
|
1098
|
+
if (svc !== null) adoptLocaleService(svc)
|
|
1099
|
+
return slots.register(
|
|
1100
|
+
// `order` 1200 deliberately sits far above every official section
|
|
1101
|
+
// (general 0, models 10, plugins 15, agent-presets 20, archived-
|
|
1102
|
+
// sessions 25) and above the third-party ones in the wild (mnemon 20,
|
|
1103
|
+
// pet 30, market 40, prompt-optimizer 90, better-sidebar 100,
|
|
1104
|
+
// version-update 140). `settings.section` is a `list` slot, so a
|
|
1105
|
+
// duplicate order does not throw — it silently makes the render order
|
|
1106
|
+
// depend on registration timing. The previous value was 26, one above
|
|
1107
|
+
// the official tail: a single new official section would have collided.
|
|
1108
|
+
//
|
|
1109
|
+
// The label: with the official locale service a FUNCTION label is
|
|
1110
|
+
// re-evaluated by the locale-aware shell on every language switch
|
|
1111
|
+
// (resolveSlotLabel), so the nav entry follows the Language setting
|
|
1112
|
+
// live. Without the service (pre-locale hosts) it must stay a plain
|
|
1113
|
+
// string — those shells render options.label directly and a function
|
|
1114
|
+
// would crash their nav.
|
|
1115
|
+
{
|
|
1116
|
+
name: 'settings.section',
|
|
1117
|
+
id: 'instruction-memory',
|
|
1118
|
+
order: 1200,
|
|
1119
|
+
label: svc !== null ? () => t('section.label') : t('section.label'),
|
|
1120
|
+
locale: NS,
|
|
1121
|
+
},
|
|
1122
|
+
() => h(InstructionPanel, null),
|
|
1123
|
+
)
|
|
1124
|
+
}))
|
|
567
1125
|
}
|
|
568
1126
|
|
|
569
1127
|
exports.name = 'instruction-memory'
|
|
@@ -573,6 +1131,15 @@ window.__ModuleLoader__.load({
|
|
|
573
1131
|
exports.apply = apply
|
|
574
1132
|
// Exposed for the contract test in verify.mjs.
|
|
575
1133
|
exports.__normalizeResult = normalizeResult
|
|
1134
|
+
exports.__draftIsDirty = draftIsDirty
|
|
1135
|
+
exports.__sameSnapshot = sameSnapshot
|
|
1136
|
+
exports.__confirmDiscard = confirmDiscard
|
|
1137
|
+
exports.__importSizeVerdict = importSizeVerdict
|
|
1138
|
+
exports.__nextBudgetText = nextBudgetText
|
|
1139
|
+
exports.__strings = STRINGS
|
|
1140
|
+
exports.__ns = NS
|
|
1141
|
+
exports.__dictionaryT = dictionaryT
|
|
1142
|
+
exports.__undoTtlMs = UNDO_TTL_MS
|
|
576
1143
|
return module.exports
|
|
577
1144
|
},
|
|
578
1145
|
})
|