dsh-instruction-memory 1.0.14 → 1.0.15

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',
@@ -70,13 +75,308 @@ window.__ModuleLoader__.load({
70
75
  .im-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-label-secondary,#8a9099); }
71
76
  .im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
72
77
  .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
73
- .im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
78
+ .im-dot-err { background:var(--dsh-alias-state-error-primary,#d94838); }
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,6 +436,52 @@ 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,
440
+ }
441
+ }
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) }),
110
485
  }
111
486
  }
112
487
 
@@ -133,11 +508,13 @@ window.__ModuleLoader__.load({
133
508
  } catch (err) {
134
509
  // A non-JSON error body leaves the status code as the only clue.
135
510
  }
136
- throw new Error('宿主返回 HTTP ' + response.status + (detail === '' ? '' : ':' + detail))
511
+ throw new Error(detail === ''
512
+ ? t('err.http', { status: response.status })
513
+ : t('err.httpDetail', { status: response.status, detail }))
137
514
  }
138
515
  return await response.json()
139
516
  } catch (err) {
140
- if (controller !== null && controller.signal.aborted) throw new Error('请求宿主超时(15 秒无响应)')
517
+ if (controller !== null && controller.signal.aborted) throw new Error(t('err.timeout'))
141
518
  throw err
142
519
  } finally {
143
520
  if (timer !== null) clearTimeout(timer)
@@ -148,63 +525,63 @@ window.__ModuleLoader__.load({
148
525
  const draft = props.draft
149
526
  const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
150
527
  const children = [
151
- h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? '新增指令' : '编辑指令'),
528
+ h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
152
529
  h('div', { key: 't' },
153
- h('label', { className: 'im-label' }, '标题'),
530
+ h('label', { className: 'im-label' }, t('editor.title')),
154
531
  h('input', {
155
532
  className: 'im-input',
156
533
  value: draft.title,
157
534
  maxLength: 120,
158
- placeholder: '例如:回答语言',
535
+ placeholder: t('editor.titlePlaceholder'),
159
536
  onChange: (ev) => set({ title: ev.target.value }),
160
537
  })),
161
538
  h('div', { key: 'c' },
162
- h('label', { className: 'im-label' }, '指令内容'),
539
+ h('label', { className: 'im-label' }, t('editor.content')),
163
540
  h('textarea', {
164
541
  className: 'im-textarea',
165
542
  value: draft.content,
166
543
  maxLength: 6000,
167
- placeholder: '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
544
+ placeholder: t('editor.contentPlaceholder'),
168
545
  onChange: (ev) => set({ content: ev.target.value }),
169
546
  })),
170
547
  h('div', { className: 'im-grid2', key: 'g' },
171
548
  h('div', null,
172
- h('label', { className: 'im-label' }, '生效方式'),
549
+ h('label', { className: 'im-label' }, t('editor.mode')),
173
550
  h('select', {
174
551
  className: 'im-select',
175
552
  value: draft.mode,
176
553
  onChange: (ev) => set({ mode: ev.target.value }),
177
554
  },
178
- h('option', { value: 'always' }, '始终生效(无条件遵守)'),
179
- h('option', { value: 'auto' }, '按需生效(按相关性自动判断)'))),
555
+ h('option', { value: 'always' }, t('editor.modeAlways')),
556
+ h('option', { value: 'auto' }, t('editor.modeAuto')))),
180
557
  h('div', null,
181
- h('label', { className: 'im-label' }, '优先级'),
558
+ h('label', { className: 'im-label' }, t('editor.priority')),
182
559
  h('select', {
183
560
  className: 'im-select',
184
561
  value: String(draft.priority),
185
562
  onChange: (ev) => set({ priority: Number(ev.target.value) }),
186
563
  },
187
- h('option', { value: '2' }, '高'),
188
- h('option', { value: '1' }, '普通'),
189
- h('option', { value: '0' }, '低')))),
564
+ h('option', { value: '2' }, t('priority.high')),
565
+ h('option', { value: '1' }, t('priority.normal')),
566
+ h('option', { value: '0' }, t('priority.low'))))),
190
567
  ]
191
568
  if (draft.mode === 'auto') {
192
569
  children.push(h('div', { key: 'w' },
193
- h('label', { className: 'im-label' }, '适用场景(可选,帮助模型判断何时生效)'),
570
+ h('label', { className: 'im-label' }, t('editor.when')),
194
571
  h('input', {
195
572
  className: 'im-input',
196
573
  value: draft.when,
197
574
  maxLength: 200,
198
- placeholder: '例如:编写或修改代码时',
575
+ placeholder: t('editor.whenPlaceholder'),
199
576
  onChange: (ev) => set({ when: ev.target.value }),
200
577
  })))
201
578
  }
202
579
  children.push(h('div', { className: 'im-row-wrap', key: 'a' },
203
580
  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 }, '取消'),
581
+ props.busy ? t('editor.saving') : t('editor.save')),
582
+ h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, t('editor.cancel')),
206
583
  h('span', { className: 'im-spacer' }),
207
- h('span', { className: 'im-meta' }, '保存后对下一轮对话立即生效')))
584
+ h('span', { className: 'im-meta' }, t('editor.hint'))))
208
585
  return h('div', { className: 'im-card' }, children)
209
586
  }
210
587
 
@@ -223,9 +600,12 @@ window.__ModuleLoader__.load({
223
600
  }, [props.text])
224
601
  return h('div', null,
225
602
  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)
603
+ overflowing ? h('div', { className: 'im-more', onClick: () => setOpen(!open) }, open ? t('body.collapse') : t('body.expand')) : null)
227
604
  }
228
605
 
606
+ /** How long the undo bar keeps its offer alive after a confirmed delete. */
607
+ const UNDO_TTL_MS = 10000
608
+
229
609
  function InstructionPanel() {
230
610
  const [view, setView] = React.useState(null)
231
611
  const [error, setError] = React.useState('')
@@ -235,17 +615,59 @@ window.__ModuleLoader__.load({
235
615
  const [budgetText, setBudgetText] = React.useState('')
236
616
  const [showPreview, setShowPreview] = React.useState(false)
237
617
  const [query, setQuery] = React.useState('')
618
+ const [undo, setUndo] = React.useState(null)
238
619
  const importInputRef = React.useRef(null)
620
+ // The latest snapshot this panel has absorbed. The SSE effect runs once
621
+ // (empty deps), so it cannot read the `view` state of a later render —
622
+ // the ref is how its frames are compared against what is on screen.
623
+ const viewRef = React.useRef(null)
624
+
625
+ /**
626
+ * Re-render on locale switches. t() reads the active locale at call
627
+ * time, so a fresh render re-translates everything; the same tick also
628
+ * covers a locale service adopted after this panel mounted (a runtime
629
+ * without ctx.inject, or one that publishes the service late).
630
+ */
631
+ const [, bumpLocale] = React.useState(0)
632
+ React.useEffect(() => {
633
+ const svc = peekLocaleService()
634
+ if (svc !== null) adoptLocaleService(svc)
635
+ return onLocaleChange(() => bumpLocale((v) => v + 1))
636
+ }, [])
637
+
638
+ /**
639
+ * The undo bar is an offer with a shelf life: it lapses on its own
640
+ * UNDO_TTL_MS after the delete, so a stale offer never outlives the
641
+ * deletion it refers to. The deadline is stamped into the bar state, not
642
+ * computed here, so the countdown starts at delete time and survives
643
+ * re-renders (including the SSE absorb of our own delete).
644
+ */
645
+ React.useEffect(() => {
646
+ if (undo === null) return undefined
647
+ const timer = setTimeout(() => setUndo(null), Math.max(0, undo.expires - Date.now()))
648
+ return () => clearTimeout(timer)
649
+ }, [undo])
650
+
651
+ /**
652
+ * The rev of the snapshot this panel is currently rendering. Sent back as
653
+ * baseRev on every mutation so the Host can refuse a write built on a
654
+ * stale view (another window or process changed the store in between)
655
+ * instead of letting it overwrite blindly. Undefined against an older
656
+ * Host whose snapshot carries no rev — JSON.stringify drops the key, so
657
+ * the request stays byte-identical to what that Host expects.
658
+ */
659
+ const revOf = () => (view !== null && view.data && typeof view.data.rev === 'number' ? view.data.rev : undefined)
239
660
 
240
661
  function absorb(result) {
241
662
  const normalized = normalizeResult(result)
242
663
  if (normalized === null) {
243
664
  // Never fail silently: the previous version returned here, leaving the
244
665
  // panel on its loading state forever with no error to act on.
245
- setError('宿主返回了无法识别的数据')
666
+ setError(t('err.unrecognized'))
246
667
  return
247
668
  }
248
669
  setView(normalized.snapshot)
670
+ viewRef.current = normalized.snapshot
249
671
  setBudgetText(String(normalized.snapshot.data.budgetChars))
250
672
  if (!normalized.ok && normalized.message) {
251
673
  setError(normalized.message)
@@ -274,7 +696,7 @@ window.__ModuleLoader__.load({
274
696
  try {
275
697
  const result = await callHost('export-data', null)
276
698
  if (result === null || typeof result !== 'object' || result.export === undefined || result.export === null) {
277
- setError('导出失败:宿主返回了无法识别的数据')
699
+ setError(t('export.failed'))
278
700
  return
279
701
  }
280
702
  const blob = new Blob([JSON.stringify(result.export, null, 2) + '\n'], { type: 'application/json' })
@@ -293,6 +715,14 @@ window.__ModuleLoader__.load({
293
715
 
294
716
  async function handleImportFile(file) {
295
717
  try {
718
+ const verdict = importSizeVerdict(
719
+ file && typeof file.size === 'number' ? file.size : NaN,
720
+ view !== null && view.limits ? view.limits.maxBodyChars : undefined,
721
+ )
722
+ if (!verdict.ok) {
723
+ setError(verdict.message)
724
+ return
725
+ }
296
726
  const text = typeof file.text === 'function'
297
727
  ? await file.text()
298
728
  : await new Promise((resolve, reject) => {
@@ -305,10 +735,10 @@ window.__ModuleLoader__.load({
305
735
  try {
306
736
  payload = JSON.parse(text)
307
737
  } catch (err) {
308
- setError('导入文件不是有效的 JSON(' + String(err && err.message ? err.message : err) + ')')
738
+ setError(t('import.notJson', { detail: String(err && err.message ? err.message : err) }))
309
739
  return
310
740
  }
311
- await send('import-data', { payload })
741
+ await send('import-data', { payload, baseRev: revOf() })
312
742
  } catch (err) {
313
743
  setError(String(err && err.message ? err.message : err))
314
744
  }
@@ -323,9 +753,34 @@ window.__ModuleLoader__.load({
323
753
  return () => { alive = false }
324
754
  }, [])
325
755
 
756
+ /**
757
+ * Live updates: the Host streams a fresh snapshot over SSE whenever the
758
+ * store changes underneath this panel — another window on the same host,
759
+ * another process sharing the file, or a hand-edited memory.json. Our
760
+ * own writes come back the same way; sameSnapshot drops that echo so a
761
+ * just-shown success notice survives. EventSource reconnects by itself
762
+ * (the Host asks for a 5s retry), and without it the panel still works
763
+ * off 重新载入 — a dead stream degrades to manual refresh, never to an
764
+ * error the user has to dismiss.
765
+ */
766
+ React.useEffect(() => {
767
+ if (typeof EventSource !== 'function') return
768
+ const source = new EventSource(API)
769
+ source.onmessage = (ev) => {
770
+ try {
771
+ const pushed = JSON.parse(ev.data)
772
+ if (sameSnapshot(pushed, viewRef.current)) return
773
+ absorb(pushed)
774
+ } catch {
775
+ // A malformed frame is dropped; the next frame, or 重新载入, catches up.
776
+ }
777
+ }
778
+ return () => { source.close() }
779
+ }, [])
780
+
326
781
  if (view === null) {
327
782
  return h('div', { className: 'im-root' },
328
- h('div', { className: 'im-empty' }, error === '' ? '正在读取指令记忆…' : error))
783
+ h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error))
329
784
  }
330
785
 
331
786
  if (draft !== null) {
@@ -334,13 +789,16 @@ window.__ModuleLoader__.load({
334
789
  draft,
335
790
  busy,
336
791
  onChange: setDraft,
337
- onCancel: () => { setDraft(null); setError('') },
792
+ onCancel: () => {
793
+ if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
794
+ setDraft(null); setError('')
795
+ },
338
796
  onSave: () => {
339
797
  if (draft.title.trim() === '' && draft.content.trim() === '') {
340
- setError('请至少填写标题或指令内容')
798
+ setError(t('editor.needTitleOrContent'))
341
799
  return
342
800
  }
343
- send('save-entry', { entry: draft }).then((result) => {
801
+ send('save-entry', { entry: draft, baseRev: revOf() }).then((result) => {
344
802
  if (result && result.ok === true) { setDraft(null); setError('') }
345
803
  })
346
804
  },
@@ -369,28 +827,28 @@ window.__ModuleLoader__.load({
369
827
  const dotText = injection.error
370
828
  ? injection.error
371
829
  : (injection.registered
372
- ? '已挂载到系统提示词——后续每轮对话自动生效(' + injection.chars + ' 字符)'
373
- : (activeCount === 0 ? '未挂载:当前没有启用中的指令' : '未挂载'))
830
+ ? t('status.mounted', { chars: injection.chars })
831
+ : (activeCount === 0 ? t('status.notMountedEmpty') : t('status.notMounted')))
374
832
 
375
833
  const header = h('div', { className: 'im-card', key: 'head' },
376
834
  h('div', { className: 'im-row' },
377
835
  h('div', null,
378
- h('h3', { className: 'im-h' }, '指令记忆'),
379
- h('p', { className: 'im-sub' }, '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。')),
836
+ h('h3', { className: 'im-h' }, t('panel.title')),
837
+ h('p', { className: 'im-sub' }, t('panel.subtitle'))),
380
838
  h('label', { className: 'im-switch' },
381
839
  h('input', {
382
840
  type: 'checkbox',
383
841
  checked: data.enabled === true,
384
842
  disabled: busy,
385
- onChange: () => send('set-options', { enabled: !data.enabled }),
843
+ onChange: () => send('set-options', { enabled: !data.enabled, baseRev: revOf() }),
386
844
  }),
387
- '总开关')),
845
+ t('panel.masterSwitch'))),
388
846
  h('div', { className: 'im-status' },
389
847
  h('span', { className: dotClass }),
390
848
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
391
849
  h('div', { className: 'im-grid2' },
392
850
  h('div', null,
393
- h('label', { className: 'im-label' }, '注入字数上限'),
851
+ h('label', { className: 'im-label' }, t('budget.label')),
394
852
  h('input', {
395
853
  className: 'im-input',
396
854
  type: 'number',
@@ -399,37 +857,40 @@ window.__ModuleLoader__.load({
399
857
  step: 100,
400
858
  value: budgetText,
401
859
  disabled: busy,
860
+ title: t('budget.unitHint'),
402
861
  onChange: (ev) => setBudgetText(ev.target.value),
403
862
  onBlur: () => {
404
863
  const parsed = Number(budgetText)
405
864
  if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
406
865
  setBudgetText(String(data.budgetChars))
407
- setError('上限需在 ' + limits.minBudget + ' ~ ' + limits.maxBudget + ' 之间')
866
+ setError(t('budget.rangeError', { min: limits.minBudget, max: limits.maxBudget }))
408
867
  return
409
868
  }
410
- send('set-options', { budgetChars: parsed })
869
+ send('set-options', { budgetChars: parsed, baseRev: revOf() })
411
870
  },
412
- })),
871
+ }),
872
+ h('div', { className: 'im-meta' },
873
+ t('budget.note', { max: limits.maxBudget, tokens: Math.round(limits.maxBudget * 0.6) }))),
413
874
  h('div', null,
414
- h('label', { className: 'im-label' }, '生效规则'),
875
+ h('label', { className: 'im-label' }, t('rules.label')),
415
876
  h('div', { className: 'im-meta' },
416
- '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。'))),
877
+ t('rules.note')))),
417
878
  entries.length > 0 ? h('div', { key: 'search' },
418
879
  h('input', {
419
880
  className: 'im-input',
420
881
  value: query,
421
- placeholder: '搜索指令:标题 / 内容 / 适用场景(共 ' + entries.length + ' 条)',
882
+ placeholder: t('search.placeholder', { count: entries.length }),
422
883
  onChange: (ev) => setQuery(ev.target.value),
423
884
  }),
424
- query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, '匹配 ' + visible.length + ' / ' + entries.length + ' 条') : null) : null,
885
+ query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length })) : null) : null,
425
886
  h('div', { className: 'im-row-wrap' },
426
887
  h('button', {
427
888
  className: 'im-btn im-btn-primary',
428
889
  disabled: busy,
429
890
  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 }, '导出'),
891
+ }, t('action.new')),
892
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, t('action.reload')),
893
+ h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, t('action.export')),
433
894
  h('button', {
434
895
  className: 'im-btn',
435
896
  disabled: busy,
@@ -437,28 +898,46 @@ window.__ModuleLoader__.load({
437
898
  const el = importInputRef.current
438
899
  if (el !== null && el !== undefined) el.click()
439
900
  },
440
- }, '导入'),
901
+ }, t('action.import')),
441
902
  h('button', {
442
903
  className: 'im-btn',
443
904
  disabled: busy,
444
905
  onClick: () => setShowPreview(!showPreview),
445
- }, showPreview ? '收起注入内容' : '查看实际注入内容'),
906
+ }, showPreview ? t('action.hidePreview') : t('action.showPreview')),
446
907
  h('span', { className: 'im-spacer' }),
447
- h('span', { className: 'im-meta' }, '共 ' + entries.length + ' 条,生效 ' + activeCount + ' 条')),
908
+ h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
448
909
  h('div', { className: 'im-divider' }),
449
- h('div', { className: 'im-meta' }, '存储文件:' + (storage.path ? storage.path : '(未写入磁盘)')),
450
910
  h('div', { className: 'im-meta' },
451
- storage.savedAt ? '最近保存:' + new Date(storage.savedAt).toLocaleString() : '尚未保存到磁盘'),
911
+ storage.path ? t('storage.file', { path: storage.path }) : t('storage.noFile')),
912
+ h('div', { className: 'im-meta' },
913
+ storage.savedAt ? t('storage.savedAt', { time: new Date(storage.savedAt).toLocaleString() }) : t('storage.notSaved')),
452
914
  storage.migratedFrom
453
- ? h('div', { className: 'im-ok' }, '已从旧位置迁移:' + storage.migratedFrom + '(原文件保留为 *.migrated)')
915
+ ? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
454
916
  : null,
455
917
  storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
456
918
  error !== '' ? h('div', { className: 'im-err' }, error) : null,
457
919
  error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
458
920
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
921
+ undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
922
+ h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
923
+ h('button', { disabled: busy, onClick: () => {
924
+ const entry = cloneEntry(undo.entry)
925
+ send('save-entry', { entry, baseRev: revOf() }).then((result) => {
926
+ if (result === null || result.ok !== true) {
927
+ // A failed restore must not eat the offer: re-arm the bar so
928
+ // the user can retry. absorb() has already pulled in the
929
+ // winning snapshot, so the next click sends its fresh rev.
930
+ setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
931
+ return
932
+ }
933
+ setUndo(null)
934
+ setNotice(t('undo.restored'))
935
+ })
936
+ } }, t('undo.restore')),
937
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => setUndo(null) }, t('undo.dismiss'))) : null,
459
938
  showPreview ? h('div', null,
460
- h('label', { className: 'im-label' }, '当前注入到每轮系统提示词的内容'),
461
- h('pre', { className: 'im-pre' }, view.preview === '' ? '(当前没有生效中的指令)' : view.preview)) : null,
939
+ h('label', { className: 'im-label' }, t('preview.label')),
940
+ h('pre', { className: 'im-pre' }, view.preview === '' ? t('preview.empty') : view.preview)) : null,
462
941
  h('input', {
463
942
  key: 'import-input',
464
943
  type: 'file',
@@ -477,10 +956,10 @@ window.__ModuleLoader__.load({
477
956
  h('span', {
478
957
  className: 'im-tag ' + (entry.mode === 'always' ? 'im-tag-always' : 'im-tag-auto'),
479
958
  key: 'mode',
480
- }, entry.mode === 'always' ? '始终' : '按需'),
959
+ }, entry.mode === 'always' ? t('tag.always') : t('tag.auto')),
481
960
  ]
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' }, '低'))
961
+ if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
962
+ else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
484
963
  return h('div', {
485
964
  className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
486
965
  key: entry.id,
@@ -497,15 +976,15 @@ window.__ModuleLoader__.load({
497
976
  onChange: () => {
498
977
  const next = cloneEntry(entry)
499
978
  next.enabled = !entry.enabled
500
- send('save-entry', { entry: next })
979
+ send('save-entry', { entry: next, baseRev: revOf() })
501
980
  },
502
981
  }),
503
- '启用'),
982
+ t('entry.enabled')),
504
983
  h('button', {
505
984
  className: 'im-btn',
506
985
  disabled: busy,
507
986
  onClick: () => { setDraft(cloneEntry(entry)); setError('') },
508
- }, '编辑'),
987
+ }, t('entry.edit')),
509
988
  h('button', {
510
989
  className: 'im-btn im-btn-danger',
511
990
  disabled: busy,
@@ -513,27 +992,44 @@ window.__ModuleLoader__.load({
513
992
  // confirm() is standard DOM, guarded anyway: if the embedded
514
993
  // shell omits it, deletion proceeds rather than breaking.
515
994
  if (typeof window.confirm === 'function'
516
- && !window.confirm('确定删除「' + (entry.title !== '' ? entry.title : '未命名指令') + '」?删除后无法恢复。')) return
517
- send('delete-entry', { id: entry.id })
995
+ && !window.confirm(t('entry.confirmDelete', {
996
+ title: entry.title !== '' ? entry.title : t('entry.untitled'),
997
+ }))) return
998
+ // Keep the full entry for the undo bar; the POST response
999
+ // (not this closure) is what confirms the deletion happened.
1000
+ setUndo(null)
1001
+ send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
1002
+ if (result === null || result.ok !== true) return
1003
+ setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
1004
+ })
518
1005
  },
519
- }, '删除')),
1006
+ }, t('entry.delete'))),
520
1007
  h(EntryBody, { text: entry.content, key: 'body' }),
521
1008
  entry.mode === 'auto' && entry.when !== ''
522
- ? h('div', { className: 'im-meta' }, '适用场景:' + entry.when)
1009
+ ? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
1010
+ : null,
1011
+ // The Host stamps updatedAt on every save; entries imported from an
1012
+ // old export (or predating the field) carry 0/undefined and show
1013
+ // nothing rather than a 1970-01-01.
1014
+ entry.updatedAt > 0
1015
+ ? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(entry.updatedAt).toLocaleString() }))
523
1016
  : null)
524
1017
  })
525
1018
 
526
1019
  const list = entries.length === 0
527
1020
  ? h('div', { className: 'im-empty', key: 'empty' },
528
- '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。')
1021
+ t('list.empty'))
529
1022
  : visible.length === 0
530
- ? h('div', { className: 'im-empty', key: 'no-match' }, '没有匹配“' + query.trim() + '”的指令。')
1023
+ ? h('div', { className: 'im-empty', key: 'no-match' }, t('list.noMatch', { query: query.trim() }))
531
1024
  : h('div', { className: 'im-list', key: 'list' }, items)
532
1025
 
533
1026
  return h('div', { className: 'im-root' }, header, list)
534
1027
  }
535
1028
 
536
1029
  function apply(ctx) {
1030
+ // Kept for lazy service lookups: the official locale service may be
1031
+ // published before or after this plugin activates.
1032
+ runtimeCtx = ctx
537
1033
  // `slots` is a declared injection (see exports.inject below), so the
538
1034
  // runtime holds this plugin until the slot service exists. The fallback
539
1035
  // covers a runtime that resolves injections lazily instead.
@@ -552,18 +1048,53 @@ window.__ModuleLoader__.load({
552
1048
  if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
553
1049
  })
554
1050
 
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
- )))
1051
+ // Locale adoption, path 1: a child fiber that runs as soon as (and as
1052
+ // long as) the official locale service exists — covering hosts where the
1053
+ // service publishes after this plugin activates. Hosts without the
1054
+ // service, or runtimes without ctx.inject, never run this; the built-in
1055
+ // fallback dictionaries keep the panel working either way.
1056
+ if (typeof ctx.inject === 'function') {
1057
+ try {
1058
+ ctx.inject(['locale'], (localeCtx) => {
1059
+ adoptLocaleService(peekLocaleService(localeCtx))
1060
+ })
1061
+ } catch (err) {
1062
+ // An exotic runtime: stay on the built-in fallback dictionaries.
1063
+ }
1064
+ }
1065
+
1066
+ ctx.effect(() => slots.inject('settings.section', () => {
1067
+ // Path 2: this factory runs when the settings shell activates the
1068
+ // slot; the shell itself declares the locale service as a dependency,
1069
+ // so on a locale-aware host the service is already published by now.
1070
+ const svc = peekLocaleService()
1071
+ if (svc !== null) adoptLocaleService(svc)
1072
+ return slots.register(
1073
+ // `order` 1200 deliberately sits far above every official section
1074
+ // (general 0, models 10, plugins 15, agent-presets 20, archived-
1075
+ // sessions 25) and above the third-party ones in the wild (mnemon 20,
1076
+ // pet 30, market 40, prompt-optimizer 90, better-sidebar 100,
1077
+ // version-update 140). `settings.section` is a `list` slot, so a
1078
+ // duplicate order does not throw — it silently makes the render order
1079
+ // depend on registration timing. The previous value was 26, one above
1080
+ // the official tail: a single new official section would have collided.
1081
+ //
1082
+ // The label: with the official locale service a FUNCTION label is
1083
+ // re-evaluated by the locale-aware shell on every language switch
1084
+ // (resolveSlotLabel), so the nav entry follows the Language setting
1085
+ // live. Without the service (pre-locale hosts) it must stay a plain
1086
+ // string — those shells render options.label directly and a function
1087
+ // would crash their nav.
1088
+ {
1089
+ name: 'settings.section',
1090
+ id: 'instruction-memory',
1091
+ order: 1200,
1092
+ label: svc !== null ? () => t('section.label') : t('section.label'),
1093
+ locale: NS,
1094
+ },
1095
+ () => h(InstructionPanel, null),
1096
+ )
1097
+ }))
567
1098
  }
568
1099
 
569
1100
  exports.name = 'instruction-memory'
@@ -573,6 +1104,14 @@ window.__ModuleLoader__.load({
573
1104
  exports.apply = apply
574
1105
  // Exposed for the contract test in verify.mjs.
575
1106
  exports.__normalizeResult = normalizeResult
1107
+ exports.__draftIsDirty = draftIsDirty
1108
+ exports.__sameSnapshot = sameSnapshot
1109
+ exports.__confirmDiscard = confirmDiscard
1110
+ exports.__importSizeVerdict = importSizeVerdict
1111
+ exports.__strings = STRINGS
1112
+ exports.__ns = NS
1113
+ exports.__dictionaryT = dictionaryT
1114
+ exports.__undoTtlMs = UNDO_TTL_MS
576
1115
  return module.exports
577
1116
  },
578
1117
  })