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/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('宿主返回 HTTP ' + response.status + (detail === '' ? '' : ':' + detail))
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('请求宿主超时(15 秒无响应)')
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 ? '收起' : '展开全部') : null)
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
- setBudgetText(String(normalized.snapshot.data.budgetChars))
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('导入文件不是有效的 JSON(' + String(err && err.message ? err.message : err) + ')')
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 === '' ? '正在读取指令记忆…' : 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: () => { setDraft(null); setError('') },
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
- ? '已挂载到系统提示词——后续每轮对话自动生效(' + injection.chars + ' 字符)'
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('上限需在 ' + limits.minBudget + ' ~ ' + limits.maxBudget + ' 之间')
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: '搜索指令:标题 / 内容 / 适用场景(共 ' + entries.length + ' 条)',
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' }, '匹配 ' + visible.length + ' / ' + entries.length + ' 条') : null) : null,
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' }, '共 ' + entries.length + ' 条,生效 ' + activeCount + ' 条')),
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.savedAt ? '最近保存:' + new Date(storage.savedAt).toLocaleString() : '尚未保存到磁盘'),
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' }, '已从旧位置迁移:' + storage.migratedFrom + '(原文件保留为 *.migrated)')
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 === '' ? '(当前没有生效中的指令)' : view.preview)) : null,
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('确定删除「' + (entry.title !== '' ? entry.title : '未命名指令') + '」?删除后无法恢复。')) return
517
- send('delete-entry', { id: entry.id })
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' }, '适用场景:' + entry.when)
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' }, '没有匹配“' + query.trim() + '”的指令。')
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
- ctx.effect(() => slots.inject('settings.section', () => slots.register(
556
- // `order` 1200 deliberately sits far above every official section
557
- // (general 0, models 10, plugins 15, agent-presets 20, archived-
558
- // sessions 25) and above the third-party ones in the wild (mnemon 20,
559
- // pet 30, market 40, prompt-optimizer 90, better-sidebar 100,
560
- // version-update 140). `settings.section` is a `list` slot, so a
561
- // duplicate order does not throw — it silently makes the render order
562
- // depend on registration timing. The previous value was 26, one above
563
- // the official tail: a single new official section would have collided.
564
- { name: 'settings.section', id: 'instruction-memory', order: 1200, label: '指令记忆' },
565
- () => h(InstructionPanel, null),
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
  })