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/CHANGELOG.md +265 -0
- package/README.md +83 -6
- package/lib/client.js +617 -78
- package/lib/index.js +505 -46
- package/package.json +4 -2
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(--
|
|
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(
|
|
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('
|
|
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 ? '
|
|
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('
|
|
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 === '' ? '
|
|
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: () => {
|
|
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
|
-
? '
|
|
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('
|
|
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: '
|
|
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' }, '
|
|
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' }, '
|
|
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.
|
|
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' }, '
|
|
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 === '' ? '
|
|
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(
|
|
517
|
-
|
|
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' }, '
|
|
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' }, '
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
})
|