dsh-instruction-memory 1.3.3 → 1.4.1

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
@@ -22,6 +22,18 @@ window.__ModuleLoader__.load({
22
22
 
23
23
  const API = '/instruction-memory/api'
24
24
 
25
+ // The Host bounds one delivery at 60 s (lib/mailer.js SMTP_TOTAL_TIMEOUT_MS).
26
+ // Waiting longer than the generic 15 s keeps the panel honest: aborting while
27
+ // the Host is still delivering would tell the user "timed out" for a mail
28
+ // that then arrives, and the obvious retry would send a second copy.
29
+ const FEEDBACK_SEND_TIMEOUT_MS = 90000
30
+
31
+ // Feedback form: the kinds are a closed set shared with the Host (see
32
+ // lib/mailer.js); the ceilings repeat the Host defaults so an older Host
33
+ // that does not publish limits.maxFeedback* still renders a safe form.
34
+ const FEEDBACK_TYPES = ['bug', 'suggestion', 'other']
35
+ const FEEDBACK_DEFAULTS = Object.freeze({ maxTitle: 120, maxBody: 4000, maxContact: 200 })
36
+
25
37
  const CSS = `
26
38
  .im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
27
39
  color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
@@ -32,6 +44,14 @@ window.__ModuleLoader__.load({
32
44
  .im-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
33
45
  .im-row-wrap { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
34
46
  .im-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
47
+ /* The desktop shell is routinely dragged narrow: below this width the two
48
+ columns squeeze labels and placeholders into three-word lines. One column
49
+ keeps the form readable; the batch tag field already wraps, it just needs
50
+ its 160px floor lifted so it does not overflow. */
51
+ @media (max-width:560px) {
52
+ .im-grid2 { grid-template-columns:1fr; }
53
+ .im-batch-tags .im-input { flex:1 1 100%; min-width:0; }
54
+ }
35
55
  .im-spacer { flex:1; }
36
56
  .im-btn { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
37
57
  color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; }
@@ -83,6 +103,9 @@ window.__ModuleLoader__.load({
83
103
  .im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
84
104
  border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
85
105
  .im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
106
+ .im-batch-tags { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:8px 12px; border-radius:10px;
107
+ border:1px dashed var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.05); }
108
+ .im-batch-tags .im-input { flex:1; min-width:160px; width:auto; }
86
109
  .im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
87
110
  background:var(--dsw-alias-bg-layer-1,#fff); }
88
111
  .im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
@@ -111,7 +134,9 @@ window.__ModuleLoader__.load({
111
134
  .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
112
135
  .im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
113
136
  .im-body-open { max-height:none; }
114
- .im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
137
+ .im-more { display:block; margin-top:4px; padding:0; border:0; background:transparent; font-family:inherit;
138
+ font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; text-align:left; }
139
+ .im-more:focus-visible { outline:2px solid var(--dsw-alias-brand-primary,#3b6cff); outline-offset:2px; border-radius:4px; }
115
140
  .im-search-meta { margin-top:5px; }
116
141
  .im-meter-line { margin-top:5px; }
117
142
  .im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
@@ -173,6 +198,16 @@ window.__ModuleLoader__.load({
173
198
  padding:6px 10px; border-radius:8px; font-size:12px;
174
199
  border:1px solid var(--dsw-alias-state-warn-primary,#d97706);
175
200
  background:var(--dsw-alias-state-warn-bg,rgba(217,119,6,.12)); color:var(--dsw-alias-state-warn-primary,#d97706); }
201
+ /* ---- 反馈邮件 ---- */
202
+ .im-feedback { display:flex; flex-direction:column; gap:8px; }
203
+ .im-feedback-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
204
+ .im-feedback-head .im-section-title { flex:1; }
205
+ .im-feedback-form { display:flex; flex-direction:column; gap:8px; padding:10px 12px; border-radius:8px;
206
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-base,#fff); }
207
+ .im-feedback-form .im-textarea { min-height:110px; }
208
+ .im-feedback-hint { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); }
209
+ .im-feedback-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
210
+ .im-feedback-actions .im-meta { flex:1; }
176
211
  @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
177
212
  `
178
213
 
@@ -224,6 +259,7 @@ window.__ModuleLoader__.load({
224
259
  'editor.hint': '保存后对下一轮对话立即生效',
225
260
  'editor.needTitleOrContent': '请至少填写标题或指令内容',
226
261
  'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
262
+ 'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
227
263
  'body.expand': '展开全部',
228
264
  'body.collapse': '收起',
229
265
  'budget.label': '注入字数上限(字符)',
@@ -243,6 +279,7 @@ window.__ModuleLoader__.load({
243
279
  'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
244
280
  'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
245
281
  'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
282
+ 'tags.remove': '移除标签 {tag}',
246
283
  'tags.duplicate': '已有同名标签',
247
284
  'tags.full': '每条最多 {max} 个标签',
248
285
  'filter.all': '全部',
@@ -270,7 +307,9 @@ window.__ModuleLoader__.load({
270
307
  'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
271
308
  'batch.tagEmpty': '未输入有效的标签',
272
309
  'batch.noneSelected': '请先勾选要操作的指令',
273
- 'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
310
+ 'batch.tagInputPlaceholder': '输入要添加的标签(多个用逗号分隔)',
311
+ 'batch.tagConfirm': '添加',
312
+ 'batch.tagCancel': '取消',
274
313
  'profiles.section': '用户形象',
275
314
  'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
276
315
  'profiles.new': '+ 新增形象',
@@ -369,6 +408,35 @@ window.__ModuleLoader__.load({
369
408
  'diag.issueWarning': '存储警告',
370
409
  'diag.issueHistory': '历史错误',
371
410
  'diag.issueSection': '注入错误',
411
+ // ---- 反馈邮件 ----
412
+ 'feedback.section': '反馈',
413
+ 'feedback.show': '反馈',
414
+ 'feedback.hide': '收起反馈',
415
+ 'feedback.intro': '遇到问题或有功能建议?填写下面的表单,通过邮件发送给插件维护者。',
416
+ 'feedback.typeLabel': '问题类型',
417
+ 'feedback.typeBug': '问题反馈(Bug)',
418
+ 'feedback.typeSuggestion': '功能建议',
419
+ 'feedback.typeOther': '其他',
420
+ 'feedback.titleLabel': '标题',
421
+ 'feedback.titlePlaceholder': '一句话概括问题或建议',
422
+ 'feedback.bodyLabel': '详细描述',
423
+ 'feedback.bodyPlaceholder': '复现步骤、期望结果、实际表现;建议请说明使用场景',
424
+ 'feedback.contactLabel': '联系方式(可选)',
425
+ 'feedback.contactPlaceholder': '邮箱 / 微信 / GitHub 用户名,便于回复你',
426
+ 'feedback.hint': '发送时会自动附带插件版本、DSH / Node 版本与运行平台,不包含你的指令内容。',
427
+ 'feedback.send': '发送反馈',
428
+ 'feedback.sending': '正在发送…',
429
+ 'feedback.clear': '清空',
430
+ 'feedback.sent': '反馈已发送,感谢你的反馈!',
431
+ 'feedback.failed': '反馈发送失败,请稍后重试。',
432
+ 'feedback.failedDetail': '反馈发送失败:{detail}',
433
+ 'feedback.timeout': '等待宿主响应超时({seconds} 秒)。邮件可能已经发出,请先确认再重试。',
434
+ 'feedback.errType': '请选择有效的问题类型',
435
+ 'feedback.errTitle': '请填写标题',
436
+ 'feedback.errTitleLong': '标题不能超过 {max} 个字符',
437
+ 'feedback.errBody': '请补充详细描述(至少 5 个字符)',
438
+ 'feedback.errBodyLong': '详细描述不能超过 {max} 个字符',
439
+ 'feedback.errContact': '联系方式不能超过 {max} 个字符',
372
440
  // ---- F7 临时静音 ----
373
441
  'mute.menu': '暂停注入…',
374
442
  'mute.30m': '30 分钟',
@@ -475,6 +543,7 @@ window.__ModuleLoader__.load({
475
543
  'editor.hint': 'Takes effect for the next conversation turn once saved',
476
544
  'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
477
545
  'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
546
+ 'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
478
547
  'body.expand': 'Show all',
479
548
  'body.collapse': 'Show less',
480
549
  'budget.label': 'Injection size limit (characters)',
@@ -494,6 +563,7 @@ window.__ModuleLoader__.load({
494
563
  'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
495
564
  'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
496
565
  'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
566
+ 'tags.remove': 'Remove tag {tag}',
497
567
  'tags.duplicate': 'This tag already exists',
498
568
  'tags.full': 'Up to {max} tags per entry',
499
569
  'filter.all': 'All',
@@ -521,7 +591,9 @@ window.__ModuleLoader__.load({
521
591
  'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
522
592
  'batch.tagEmpty': 'No valid tag entered',
523
593
  'batch.noneSelected': 'Tick the entries to act on first',
524
- 'batch.promptUnavailable': 'Quick entry is unavailable in this environment; edit tags one by one',
594
+ 'batch.tagInputPlaceholder': 'Tags to add (separate several with commas)',
595
+ 'batch.tagConfirm': 'Add',
596
+ 'batch.tagCancel': 'Cancel',
525
597
  'profiles.section': 'User persona',
526
598
  'profiles.subtitle': 'Facts about you: background, tastes, personality and goals. When “Use this persona” is ticked, the AI adjusts depth, examples and advice to tilt toward you — while staying correct and on-topic; it must never change facts or wander off the question to please you.',
527
599
  'profiles.new': '+ New persona',
@@ -620,6 +692,35 @@ window.__ModuleLoader__.load({
620
692
  'diag.issueWarning': 'Storage warning',
621
693
  'diag.issueHistory': 'History error',
622
694
  'diag.issueSection': 'Injection error',
695
+ // ---- feedback mail ----
696
+ 'feedback.section': 'Feedback',
697
+ 'feedback.show': 'Feedback',
698
+ 'feedback.hide': 'Hide feedback',
699
+ 'feedback.intro': 'Found a problem or have a suggestion? Send it to the plugin maintainer by email with the form below.',
700
+ 'feedback.typeLabel': 'Category',
701
+ 'feedback.typeBug': 'Problem report (bug)',
702
+ 'feedback.typeSuggestion': 'Feature request',
703
+ 'feedback.typeOther': 'Other',
704
+ 'feedback.titleLabel': 'Title',
705
+ 'feedback.titlePlaceholder': 'Sum up the problem or suggestion in one line',
706
+ 'feedback.bodyLabel': 'Details',
707
+ 'feedback.bodyPlaceholder': 'Steps to reproduce, expected result, actual result; for a suggestion, describe the use case',
708
+ 'feedback.contactLabel': 'Contact (optional)',
709
+ 'feedback.contactPlaceholder': 'Email / WeChat / GitHub handle so the maintainer can reply',
710
+ 'feedback.hint': 'The mail automatically includes the plugin version, DSH / Node versions and platform — never your instruction content.',
711
+ 'feedback.send': 'Send feedback',
712
+ 'feedback.sending': 'Sending…',
713
+ 'feedback.clear': 'Clear',
714
+ 'feedback.sent': 'Feedback sent — thank you!',
715
+ 'feedback.failed': 'Could not send the feedback. Please try again later.',
716
+ 'feedback.failedDetail': 'Could not send the feedback: {detail}',
717
+ 'feedback.timeout': 'Timed out waiting for the host ({seconds} s). The mail may already have been sent — check before retrying.',
718
+ 'feedback.errType': 'Choose a valid category',
719
+ 'feedback.errTitle': 'Enter a title',
720
+ 'feedback.errTitleLong': 'The title must be at most {max} characters',
721
+ 'feedback.errBody': 'Add a description (at least 5 characters)',
722
+ 'feedback.errBodyLong': 'The description must be at most {max} characters',
723
+ 'feedback.errContact': 'The contact must be at most {max} characters',
623
724
  // ---- F7 temporary mute ----
624
725
  'mute.menu': 'Pause injection…',
625
726
  'mute.30m': '30 minutes',
@@ -815,32 +916,95 @@ window.__ModuleLoader__.load({
815
916
 
816
917
  /* ---------------- data helpers ---------------- */
817
918
 
818
- // Mirror the Host's tag limits (lib/index.js); kept local so the editor
819
- // constrains input before the round-trip and tests can pin both to match.
919
+ // Mirror the Host's tag limits (lib/index.js) as FALLBACKS ONLY. The Host
920
+ // publishes the authoritative values in snapshot.limits; these constants
921
+ // keep the editor usable against an older Host that predates them.
820
922
  const MAX_TAGS = 8
821
923
  const MAX_TAG_LEN = 24
924
+ const DEFAULT_MAX_TITLE = 120
925
+ const DEFAULT_MAX_CONTENT = 6000
926
+ const DEFAULT_MAX_WHEN = 200
927
+
928
+ /**
929
+ * Per-field ceilings for the entry editor.
930
+ *
931
+ * These used to be hard-coded at the call sites (120/6000/200, tags 8/24),
932
+ * which meant a Host-side limit change left the panel silently disagreeing
933
+ * with the loader: the editor accepted text the Host then truncated, with a
934
+ * notice the user could not have predicted. The Host now publishes every
935
+ * one of them; the historical defaults remain only for older Hosts.
936
+ */
937
+ function fieldLimitsOf(limits) {
938
+ const source = limits !== null && typeof limits === 'object' ? limits : {}
939
+ const pick = (key, fallback) => (
940
+ typeof source[key] === 'number' && Number.isFinite(source[key]) && source[key] > 0 ? source[key] : fallback
941
+ )
942
+ return {
943
+ maxTitle: pick('maxTitle', DEFAULT_MAX_TITLE),
944
+ maxContent: pick('maxContent', DEFAULT_MAX_CONTENT),
945
+ maxWhen: pick('maxWhen', DEFAULT_MAX_WHEN),
946
+ maxTags: pick('maxTagsPerEntry', MAX_TAGS),
947
+ maxTagLength: pick('maxTagLength', MAX_TAG_LEN),
948
+ }
949
+ }
822
950
 
823
951
  /**
824
952
  * Parse free text typed into the tag box into normalised tag candidates.
825
953
  * Tags are split on commas (ASCII/full-width), semicolons and newlines —
826
954
  * never spaces, so a tag may contain spaces. Each piece is trimmed,
827
- * collapsed to one line, cut to MAX_TAG_LEN, de-duplicated and capped at
828
- * MAX_TAGS. Pinned as a pure export.
955
+ * collapsed to one line, cut to the length cap, de-duplicated and capped at
956
+ * the count cap. Both caps come from the snapshot when supplied. Pinned as
957
+ * a pure export.
829
958
  */
830
- function parseTagInput(text) {
959
+ function parseTagInput(text, limits) {
831
960
  if (typeof text !== 'string') return []
961
+ const caps = fieldLimitsOf(limits)
832
962
  const out = []
833
963
  const seen = new Set()
834
964
  for (const piece of text.split(/[,,;;\n\r]+/)) {
835
- const tag = piece.replace(/\s+/g, ' ').trim().slice(0, MAX_TAG_LEN)
965
+ const tag = piece.replace(/\s+/g, ' ').trim().slice(0, caps.maxTagLength)
836
966
  if (tag === '' || seen.has(tag)) continue
837
967
  seen.add(tag)
838
- if (out.length >= MAX_TAGS) break
968
+ if (out.length >= caps.maxTags) break
839
969
  out.push(tag)
840
970
  }
841
971
  return out
842
972
  }
843
973
 
974
+ /**
975
+ * Decide what a `window.prompt` call site should do, without assuming the
976
+ * API works. Pinned as a pure export so the Electron path is testable in
977
+ * Node.
978
+ *
979
+ * Three outcomes, and the difference between the last two is the whole
980
+ * point: Electron 44 installs
981
+ * `window.prompt = function () { throw new Error("prompt() is not
982
+ * supported.") }` (and propagates it through contextBridge), so `typeof`
983
+ * still reports 'function' and a type guard cannot tell it apart from a
984
+ * real prompt. Cancel returns `null` from a WORKING prompt and must stay a
985
+ * deliberate no; only a throw (or a missing API) may open the fallback form.
986
+ *
987
+ * @param {((message: string, initial: string) => string | null) | null} ask
988
+ * @param {string} message
989
+ * @returns {{ kind: 'unavailable' } | { kind: 'cancel' } | { kind: 'answer', text: string }}
990
+ */
991
+ function batchTagOutcome(ask, message) {
992
+ if (typeof ask !== 'function') return { kind: 'unavailable' }
993
+ let answer
994
+ try {
995
+ answer = ask(message, '')
996
+ } catch (err) {
997
+ return { kind: 'unavailable' }
998
+ }
999
+ // Only a real `null` means the user pressed Cancel. `undefined` and any
1000
+ // non-string (a broken polyfill, a Promise) mean the runtime cannot
1001
+ // deliver an answer, so the caller must show the fallback form instead of
1002
+ // swallowing the click.
1003
+ if (answer === null) return { kind: 'cancel' }
1004
+ if (typeof answer !== 'string') return { kind: 'unavailable' }
1005
+ return { kind: 'answer', text: answer }
1006
+ }
1007
+
844
1008
  function cloneEntry(entry) {
845
1009
  return {
846
1010
  id: entry.id,
@@ -905,15 +1069,58 @@ window.__ModuleLoader__.load({
905
1069
  return tagsKey(draft) !== tagsKey(base)
906
1070
  }
907
1071
 
1072
+ const CONFIRM_YES = 'yes'
1073
+ const CONFIRM_NO = 'no'
1074
+ const CONFIRM_UNAVAILABLE = 'unavailable'
1075
+
1076
+ /**
1077
+ * The single gate every confirmation goes through.
1078
+ *
1079
+ * Two different fallbacks used to be hard-coded at the call sites, which
1080
+ * meant the same class of irreversible action behaved differently
1081
+ * depending on the shell: bulk delete, set delete, persona delete and
1082
+ * single-entry delete all PROCEEDED when `window.confirm` was missing
1083
+ * (`typeof confirm === 'function' && !confirm(…)` short-circuits to false),
1084
+ * while version rollback and whole-store restore REFUSED. On top of that,
1085
+ * Electron 44 ships a `prompt()` that exists but throws — `typeof` still
1086
+ * says 'function' — so a `typeof`-only guard proves nothing.
1087
+ *
1088
+ * Three outcomes, not two, because "the user said no" and "this shell has
1089
+ * no dialog" need different UI: the first is silent, the second must tell
1090
+ * the user why nothing happened instead of swallowing the click.
1091
+ *
1092
+ * `unavailable` makes the fallback explicit per call site, chosen by what
1093
+ * is actually at stake:
1094
+ * - 'deny' (default) — durable data (entries, sets, personas, history,
1095
+ * a whole-store restore). If no dialog exists, do NOT destroy
1096
+ * anything.
1097
+ * - 'allow' — unsaved drafts only (leaving the editor, reloading the
1098
+ * source view). Nothing on disk is lost, and refusing here
1099
+ * would trap the user with a dead button, which is why
1100
+ * verify.mjs pins this fallback.
1101
+ */
1102
+ function askConfirm(message, unavailable = 'deny') {
1103
+ const fallback = unavailable === 'allow'
1104
+ if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
1105
+ return fallback ? CONFIRM_YES : CONFIRM_UNAVAILABLE
1106
+ }
1107
+ try {
1108
+ return window.confirm(message) === true ? CONFIRM_YES : CONFIRM_NO
1109
+ } catch {
1110
+ // A dialog stub that exists but throws (the Electron 44 `prompt()`
1111
+ // pattern, seen in the desktop shell).
1112
+ return fallback ? CONFIRM_YES : CONFIRM_UNAVAILABLE
1113
+ }
1114
+ }
1115
+
908
1116
  /**
909
1117
  * Ask before throwing the draft away. Falls back to "yes" when no dialog
910
1118
  * is available (a bare webview without window.confirm): blocking the
911
- * cancel button entirely would be worse than the old instant discard.
1119
+ * cancel button entirely would be worse than the old instant discard, and
1120
+ * no saved data is lost either way.
912
1121
  */
913
1122
  function confirmDiscard() {
914
- return typeof window !== 'undefined' && typeof window.confirm === 'function'
915
- ? window.confirm(t('editor.confirmDiscard'))
916
- : true
1123
+ return askConfirm(t('editor.confirmDiscard'), 'allow') === CONFIRM_YES
917
1124
  }
918
1125
 
919
1126
  /**
@@ -1244,11 +1451,14 @@ window.__ModuleLoader__.load({
1244
1451
  }
1245
1452
  }
1246
1453
 
1247
- async function callHost(method, args) {
1454
+ async function callHost(method, args, options) {
1248
1455
  // A hung host used to pin the panel on 保存中… forever; fail visibly
1249
- // after a generous timeout instead.
1456
+ // after a generous timeout instead. A caller that knows its request is
1457
+ // slow (a feedback send) passes its own budget and its own wording.
1458
+ const settings = options !== null && typeof options === 'object' ? options : {}
1459
+ const timeoutMs = typeof settings.timeoutMs === 'number' && settings.timeoutMs > 0 ? settings.timeoutMs : 15000
1250
1460
  const controller = typeof AbortController === 'function' ? new AbortController() : null
1251
- const timer = controller !== null ? setTimeout(() => controller.abort(), 15000) : null
1461
+ const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
1252
1462
  try {
1253
1463
  const response = await fetch(API, {
1254
1464
  method: 'POST',
@@ -1273,40 +1483,78 @@ window.__ModuleLoader__.load({
1273
1483
  }
1274
1484
  return await response.json()
1275
1485
  } catch (err) {
1276
- if (controller !== null && controller.signal.aborted) throw new Error(t('err.timeout'))
1486
+ if (controller !== null && controller.signal.aborted) {
1487
+ throw new Error(typeof settings.timeoutMessage === 'string' && settings.timeoutMessage !== ''
1488
+ ? settings.timeoutMessage
1489
+ : t('err.timeout'))
1490
+ }
1277
1491
  throw err
1278
1492
  } finally {
1279
1493
  if (timer !== null) clearTimeout(timer)
1280
1494
  }
1281
1495
  }
1282
1496
 
1497
+ /**
1498
+ * Client-side mirror of the Host's normalizeFeedback. Returns either
1499
+ * `{ ok: true, value }` (trimmed) or `{ ok: false, message }` (already
1500
+ * translated); the Host re-validates and stays authoritative (a forged
1501
+ * request can bypass every check here).
1502
+ */
1503
+ function feedbackVerdict(type, title, body, contact, limits) {
1504
+ const source = limits !== null && typeof limits === 'object' ? limits : FEEDBACK_DEFAULTS
1505
+ const maxTitle = typeof source.maxFeedbackTitle === 'number' ? source.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle
1506
+ const maxBody = typeof source.maxFeedbackBody === 'number' ? source.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody
1507
+ const maxContact = typeof source.maxFeedbackContact === 'number' ? source.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact
1508
+ const kind = typeof type === 'string' ? type : ''
1509
+ if (!FEEDBACK_TYPES.includes(kind)) return { ok: false, message: t('feedback.errType') }
1510
+ const cleanTitle = typeof title === 'string' ? title.trim() : ''
1511
+ if (cleanTitle === '') return { ok: false, message: t('feedback.errTitle') }
1512
+ if (cleanTitle.length > maxTitle) {
1513
+ return { ok: false, message: t('feedback.errTitleLong', { max: maxTitle }) }
1514
+ }
1515
+ const cleanBody = typeof body === 'string' ? body.trim() : ''
1516
+ if (cleanBody.length < 5) return { ok: false, message: t('feedback.errBody') }
1517
+ if (cleanBody.length > maxBody) {
1518
+ return { ok: false, message: t('feedback.errBodyLong', { max: maxBody }) }
1519
+ }
1520
+ const cleanContact = typeof contact === 'string' ? contact.trim() : ''
1521
+ if (cleanContact.length > maxContact) {
1522
+ return { ok: false, message: t('feedback.errContact', { max: maxContact }) }
1523
+ }
1524
+ return { ok: true, value: { type: kind, title: cleanTitle, body: cleanBody, contact: cleanContact } }
1525
+ }
1526
+
1283
1527
  function EntryEditor(props) {
1284
1528
  const draft = props.draft
1529
+ const caps = fieldLimitsOf(props.limits)
1285
1530
  const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
1286
1531
  const children = [
1287
1532
  h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
1288
1533
  h('div', { key: 't' },
1289
- h('label', { className: 'im-label' }, t('editor.title')),
1534
+ h('label', { className: 'im-label', htmlFor: 'im-entry-title' }, t('editor.title')),
1290
1535
  h('input', {
1536
+ id: 'im-entry-title',
1291
1537
  className: 'im-input',
1292
1538
  value: draft.title,
1293
- maxLength: 120,
1539
+ maxLength: caps.maxTitle,
1294
1540
  placeholder: t('editor.titlePlaceholder'),
1295
1541
  onChange: (ev) => set({ title: ev.target.value }),
1296
1542
  })),
1297
1543
  h('div', { key: 'c' },
1298
- h('label', { className: 'im-label' }, t('editor.content')),
1544
+ h('label', { className: 'im-label', htmlFor: 'im-entry-content' }, t('editor.content')),
1299
1545
  h('textarea', {
1546
+ id: 'im-entry-content',
1300
1547
  className: 'im-textarea',
1301
1548
  value: draft.content,
1302
- maxLength: 6000,
1549
+ maxLength: caps.maxContent,
1303
1550
  placeholder: t('editor.contentPlaceholder'),
1304
1551
  onChange: (ev) => set({ content: ev.target.value }),
1305
1552
  })),
1306
1553
  h('div', { className: 'im-grid2', key: 'g' },
1307
1554
  h('div', null,
1308
- h('label', { className: 'im-label' }, t('editor.mode')),
1555
+ h('label', { className: 'im-label', htmlFor: 'im-entry-mode' }, t('editor.mode')),
1309
1556
  h('select', {
1557
+ id: 'im-entry-mode',
1310
1558
  className: 'im-select',
1311
1559
  value: draft.mode,
1312
1560
  onChange: (ev) => set({ mode: ev.target.value }),
@@ -1314,8 +1562,9 @@ window.__ModuleLoader__.load({
1314
1562
  h('option', { value: 'always' }, t('editor.modeAlways')),
1315
1563
  h('option', { value: 'auto' }, t('editor.modeAuto')))),
1316
1564
  h('div', null,
1317
- h('label', { className: 'im-label' }, t('editor.priority')),
1565
+ h('label', { className: 'im-label', htmlFor: 'im-entry-priority' }, t('editor.priority')),
1318
1566
  h('select', {
1567
+ id: 'im-entry-priority',
1319
1568
  className: 'im-select',
1320
1569
  value: String(draft.priority),
1321
1570
  onChange: (ev) => set({ priority: Number(ev.target.value) }),
@@ -1326,19 +1575,22 @@ window.__ModuleLoader__.load({
1326
1575
  ]
1327
1576
  if (draft.mode === 'auto') {
1328
1577
  children.push(h('div', { key: 'w' },
1329
- h('label', { className: 'im-label' }, t('editor.when')),
1578
+ h('label', { className: 'im-label', htmlFor: 'im-entry-when' }, t('editor.when')),
1330
1579
  h('input', {
1580
+ id: 'im-entry-when',
1331
1581
  className: 'im-input',
1332
1582
  value: draft.when,
1333
- maxLength: 200,
1583
+ maxLength: caps.maxWhen,
1334
1584
  placeholder: t('editor.whenPlaceholder'),
1335
1585
  onChange: (ev) => set({ when: ev.target.value }),
1336
1586
  })))
1337
1587
  }
1338
1588
  children.push(h('div', { key: 'tags' },
1339
- h('label', { className: 'im-label' }, t('tags.label')),
1589
+ h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
1340
1590
  h(TagInput, {
1591
+ inputId: 'im-entry-tags',
1341
1592
  tags: draft.tags,
1593
+ limits: props.limits,
1342
1594
  disabled: props.busy,
1343
1595
  onChange: (next) => set({ tags: next }),
1344
1596
  })))
@@ -1360,15 +1612,16 @@ window.__ModuleLoader__.load({
1360
1612
  function TagInput(props) {
1361
1613
  const [text, setText] = React.useState('')
1362
1614
  const tags = Array.isArray(props.tags) ? props.tags : []
1615
+ const caps = fieldLimitsOf(props.limits)
1363
1616
  const merge = (pieces) => {
1364
1617
  const merged = []
1365
1618
  for (const tag of [...tags, ...pieces]) {
1366
1619
  if (!merged.includes(tag)) merged.push(tag)
1367
1620
  }
1368
- props.onChange(merged.slice(0, MAX_TAGS))
1621
+ props.onChange(merged.slice(0, caps.maxTags))
1369
1622
  }
1370
1623
  const commit = () => {
1371
- const parsed = parseTagInput(text)
1624
+ const parsed = parseTagInput(text, props.limits)
1372
1625
  if (parsed.length > 0) merge(parsed)
1373
1626
  setText('')
1374
1627
  }
@@ -1382,14 +1635,19 @@ window.__ModuleLoader__.load({
1382
1635
  tag,
1383
1636
  h('button', {
1384
1637
  type: 'button', className: 'im-chip-x', disabled: props.disabled,
1638
+ // A lone "×" is not an accessible name: a screen reader
1639
+ // announces it as "times" with no idea which tag it removes.
1640
+ 'aria-label': t('tags.remove', { tag }),
1641
+ title: t('tags.remove', { tag }),
1385
1642
  onClick: () => remove(tag),
1386
1643
  }, '×'))))
1387
1644
  : null,
1388
1645
  h('input', {
1646
+ id: props.inputId,
1389
1647
  className: 'im-input',
1390
1648
  value: text,
1391
1649
  disabled: props.disabled,
1392
- maxLength: MAX_TAG_LEN,
1650
+ maxLength: caps.maxTagLength,
1393
1651
  placeholder: t('tags.placeholder'),
1394
1652
  onChange: (ev) => {
1395
1653
  const v = ev.target.value
@@ -1398,11 +1656,11 @@ window.__ModuleLoader__.load({
1398
1656
  // v is the input's full new value (it already contains what was
1399
1657
  // typed before the separator) — do NOT prepend `text`, that
1400
1658
  // duplicated it ("b" + "b," -> "bb").
1401
- const parsed = parseTagInput(v)
1659
+ const parsed = parseTagInput(v, props.limits)
1402
1660
  if (parsed.length > 0) merge(parsed)
1403
1661
  setText('')
1404
1662
  } else {
1405
- setText(v.slice(0, MAX_TAG_LEN))
1663
+ setText(v.slice(0, caps.maxTagLength))
1406
1664
  }
1407
1665
  },
1408
1666
  onKeyDown: (ev) => {
@@ -1415,7 +1673,7 @@ window.__ModuleLoader__.load({
1415
1673
  },
1416
1674
  onBlur: commit,
1417
1675
  }),
1418
- h('div', { className: 'im-meta' }, t('tags.hint', { max: MAX_TAGS, len: MAX_TAG_LEN })))
1676
+ h('div', { className: 'im-meta' }, t('tags.hint', { max: caps.maxTags, len: caps.maxTagLength })))
1419
1677
  }
1420
1678
 
1421
1679
  /**
@@ -1433,7 +1691,12 @@ window.__ModuleLoader__.load({
1433
1691
  }, [props.text])
1434
1692
  return h('div', null,
1435
1693
  h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
1436
- overflowing ? h('div', { className: 'im-more', onClick: () => setOpen(!open) }, open ? t('body.collapse') : t('body.expand')) : null)
1694
+ overflowing ? h('button', {
1695
+ type: 'button',
1696
+ className: 'im-more',
1697
+ 'aria-expanded': open,
1698
+ onClick: () => setOpen(!open),
1699
+ }, open ? t('body.collapse') : t('body.expand')) : null)
1437
1700
  }
1438
1701
 
1439
1702
  /** How long the undo bar keeps its offer alive after a confirmed delete. */
@@ -1454,8 +1717,9 @@ window.__ModuleLoader__.load({
1454
1717
  const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
1455
1718
  return h('div', { className: 'im-editor im-profiles' },
1456
1719
  h('div', null,
1457
- h('label', { className: 'im-label' }, t('profiles.name')),
1720
+ h('label', { className: 'im-label', htmlFor: 'im-profile-name' }, t('profiles.name')),
1458
1721
  h('input', {
1722
+ id: 'im-profile-name',
1459
1723
  className: 'im-input',
1460
1724
  value: props.draft.name,
1461
1725
  maxLength: maxName,
@@ -1464,8 +1728,9 @@ window.__ModuleLoader__.load({
1464
1728
  onChange: (ev) => set({ name: ev.target.value }),
1465
1729
  })),
1466
1730
  h('div', null,
1467
- h('label', { className: 'im-label' }, t('profiles.content')),
1731
+ h('label', { className: 'im-label', htmlFor: 'im-profile-content' }, t('profiles.content')),
1468
1732
  h('textarea', {
1733
+ id: 'im-profile-content',
1469
1734
  className: 'im-textarea',
1470
1735
  rows: 6,
1471
1736
  value: props.draft.content,
@@ -1519,6 +1784,13 @@ window.__ModuleLoader__.load({
1519
1784
  // F3: bulk-select mode and the chosen entry ids in it.
1520
1785
  const [batchMode, setBatchMode] = React.useState(false)
1521
1786
  const [selected, setSelected] = React.useState([])
1787
+ // The bulk "add tags" input. `window.prompt` is the fast path on a plain
1788
+ // browser, but Electron REPLACES it with a function that throws
1789
+ // ("prompt() is not supported.") — typeof still says 'function', so a
1790
+ // type guard cannot catch it. `batchTagOpen` is the fallback form, shown
1791
+ // whenever prompt is missing OR throws.
1792
+ const [batchTagOpen, setBatchTagOpen] = React.useState(false)
1793
+ const [batchTagText, setBatchTagText] = React.useState('')
1522
1794
  // F13: the set form (null = closed). One small inline form serves create,
1523
1795
  // rename and duplicate, because all three are "type a name and confirm".
1524
1796
  const [setForm, setSetForm] = React.useState(null)
@@ -1548,6 +1820,18 @@ window.__ModuleLoader__.load({
1548
1820
  // pretending the copy happened.
1549
1821
  const [diagOpen, setDiagOpen] = React.useState(false)
1550
1822
  const [diagFallback, setDiagFallback] = React.useState(null)
1823
+ // Feedback form: closed by default so the settings card stays compact.
1824
+ // The draft lives in the panel (not in a child) because the send handler
1825
+ // needs the same values the inputs show, and keeping them here survives
1826
+ // an SSE snapshot absorb while the user is typing.
1827
+ const [feedbackOpen, setFeedbackOpen] = React.useState(false)
1828
+ const [feedbackType, setFeedbackType] = React.useState('bug')
1829
+ const [feedbackTitle, setFeedbackTitle] = React.useState('')
1830
+ const [feedbackBody, setFeedbackBody] = React.useState('')
1831
+ const [feedbackContact, setFeedbackContact] = React.useState('')
1832
+ const [feedbackBusy, setFeedbackBusy] = React.useState(false)
1833
+ const [feedbackError, setFeedbackError] = React.useState('')
1834
+ const [feedbackSent, setFeedbackSent] = React.useState('')
1551
1835
  // F7: the pause menu (null-state boolean) and a 1s clock tick that exists
1552
1836
  // ONLY while a pause is live (its effect cleans up after itself). The
1553
1837
  // tick drives nothing but the remaining-time text; authority stays with
@@ -1705,6 +1989,12 @@ window.__ModuleLoader__.load({
1705
1989
  function exitBatch() {
1706
1990
  setBatchMode(false)
1707
1991
  setSelected([])
1992
+ closeBatchTags()
1993
+ }
1994
+
1995
+ function closeBatchTags() {
1996
+ setBatchTagOpen(false)
1997
+ setBatchTagText('')
1708
1998
  }
1709
1999
 
1710
2000
  async function doBatch(op, value) {
@@ -1713,10 +2003,12 @@ window.__ModuleLoader__.load({
1713
2003
  setError(t('batch.noneSelected'))
1714
2004
  return
1715
2005
  }
1716
- if (op === 'delete'
1717
- && typeof window.confirm === 'function'
1718
- && !window.confirm(t('batch.confirmDelete', { count: ids.length }))) {
1719
- return
2006
+ if (op === 'delete') {
2007
+ const ask = askConfirm(t('batch.confirmDelete', { count: ids.length }))
2008
+ if (ask !== CONFIRM_YES) {
2009
+ if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2010
+ return
2011
+ }
1720
2012
  }
1721
2013
  const result = await send('batch', { op, ids, value, baseRev: revOf() })
1722
2014
  if (result !== null && result.ok === true && op === 'delete') {
@@ -1726,19 +2018,41 @@ window.__ModuleLoader__.load({
1726
2018
  }
1727
2019
  }
1728
2020
 
2021
+ /** Parse a raw tag string and fire the bulk op. Returns false when empty. */
2022
+ function commitBatchTags(raw) {
2023
+ const tags = parseTagInput(raw === undefined || raw === null ? '' : String(raw), view.limits)
2024
+ if (tags.length === 0) {
2025
+ setError(t('batch.tagEmpty'))
2026
+ return false
2027
+ }
2028
+ setError('')
2029
+ void doBatch('add-tags', tags)
2030
+ return true
2031
+ }
2032
+
2033
+ /**
2034
+ * Bulk "add tags": `window.prompt` when the runtime really implements it,
2035
+ * the inline form otherwise. See batchTagOutcome() for why `typeof` alone
2036
+ * cannot make that call. The wrapper (rather than passing `window.prompt`
2037
+ * by value) keeps `this === window`, which WebIDL operations require.
2038
+ */
1729
2039
  function batchAddTags() {
1730
- if (typeof window.prompt !== 'function') {
1731
- setError(t('batch.promptUnavailable'))
2040
+ const ids = liveSelected()
2041
+ if (ids.length === 0) {
2042
+ setError(t('batch.noneSelected'))
1732
2043
  return
1733
2044
  }
1734
- const answer = window.prompt(t('batch.tagPrompt', { count: liveSelected().length }), '')
1735
- if (answer === null) return
1736
- const tags = parseTagInput(answer)
1737
- if (tags.length === 0) {
1738
- setError(t('batch.tagEmpty'))
2045
+ const ask = typeof window.prompt === 'function'
2046
+ ? (message, initial) => window.prompt(message, initial)
2047
+ : null
2048
+ const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }))
2049
+ if (outcome.kind === 'cancel') return
2050
+ if (outcome.kind === 'answer') {
2051
+ if (commitBatchTags(outcome.text)) closeBatchTags()
1739
2052
  return
1740
2053
  }
1741
- void doBatch('add-tags', tags)
2054
+ setError('')
2055
+ setBatchTagOpen(true)
1742
2056
  }
1743
2057
 
1744
2058
  // ---- User persona ("用户形象") handlers ----
@@ -1767,10 +2081,13 @@ window.__ModuleLoader__.load({
1767
2081
 
1768
2082
  function removeProfile(profile) {
1769
2083
  if (busy) return
1770
- if (typeof window.confirm === 'function'
1771
- && !window.confirm(t('profiles.confirmDelete', {
1772
- name: profile.name !== '' ? profile.name : t('profiles.untitled'),
1773
- }))) return
2084
+ const ask = askConfirm(t('profiles.confirmDelete', {
2085
+ name: profile.name !== '' ? profile.name : t('profiles.untitled'),
2086
+ }))
2087
+ if (ask !== CONFIRM_YES) {
2088
+ if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2089
+ return
2090
+ }
1774
2091
  void send('delete-profile', { id: profile.id, baseRev: revOf() })
1775
2092
  }
1776
2093
 
@@ -1828,8 +2145,11 @@ window.__ModuleLoader__.load({
1828
2145
  setError(t('sets.deleteActive'))
1829
2146
  return
1830
2147
  }
1831
- if (typeof window.confirm === 'function'
1832
- && !window.confirm(t('sets.deleteConfirm', { name: set.name, entries: set.entries, profiles: set.profiles }))) return
2148
+ const askDeleteSet = askConfirm(t('sets.deleteConfirm', { name: set.name, entries: set.entries, profiles: set.profiles }))
2149
+ if (askDeleteSet !== CONFIRM_YES) {
2150
+ if (askDeleteSet === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2151
+ return
2152
+ }
1833
2153
  void send('delete-set', { id: set.id, baseRev: revOf() })
1834
2154
  }
1835
2155
 
@@ -1861,9 +2181,13 @@ window.__ModuleLoader__.load({
1861
2181
  }
1862
2182
 
1863
2183
  function rollbackTo(item) {
1864
- if (busy || typeof window.confirm !== 'function') return
2184
+ if (busy) return
1865
2185
  const when = new Date(item.epoch).toLocaleString()
1866
- if (!window.confirm(t('history.restoreConfirm', { time: when, rev: item.rev }))) return
2186
+ const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
2187
+ if (ask !== CONFIRM_YES) {
2188
+ if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2189
+ return
2190
+ }
1867
2191
  void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
1868
2192
  if (result !== null && result.ok === true) void loadHistory()
1869
2193
  })
@@ -1871,12 +2195,97 @@ window.__ModuleLoader__.load({
1871
2195
 
1872
2196
  function removeSnapshot(item) {
1873
2197
  if (busy) return
1874
- if (typeof window.confirm === 'function' && !window.confirm(t('history.deleteConfirm'))) return
2198
+ const askDeleteHistory = askConfirm(t('history.deleteConfirm'))
2199
+ if (askDeleteHistory !== CONFIRM_YES) {
2200
+ if (askDeleteHistory === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2201
+ return
2202
+ }
1875
2203
  void send('history-delete', { name: item.name }).then((result) => {
1876
2204
  if (result !== null && result.ok === true) void loadHistory()
1877
2205
  })
1878
2206
  }
1879
2207
 
2208
+ // ---- 反馈邮件 ----
2209
+
2210
+ function toggleFeedback() {
2211
+ const next = !feedbackOpen
2212
+ setFeedbackOpen(next)
2213
+ setFeedbackError('')
2214
+ if (!next) setFeedbackSent('')
2215
+ }
2216
+
2217
+ function clearFeedback() {
2218
+ setFeedbackType('bug')
2219
+ setFeedbackTitle('')
2220
+ setFeedbackBody('')
2221
+ setFeedbackContact('')
2222
+ setFeedbackError('')
2223
+ setFeedbackSent('')
2224
+ }
2225
+
2226
+ /**
2227
+ * Send one report. The host validates again, enforces its own ceiling and
2228
+ * a cool-down, and decides the recipient from configuration — the panel
2229
+ * only supplies text plus the presentation facts (locale / time zone /
2230
+ * user agent). The error log deliberately carries the same reason the
2231
+ * user sees: a send failure is a configuration or network problem, and
2232
+ * the console is where a maintainer will look for it.
2233
+ */
2234
+ async function submitFeedback() {
2235
+ if (feedbackBusy) return
2236
+ const verdict = feedbackVerdict(feedbackType, feedbackTitle, feedbackBody, feedbackContact, view !== null ? view.limits : null)
2237
+ if (!verdict.ok) {
2238
+ setFeedbackSent('')
2239
+ setFeedbackError(verdict.message)
2240
+ return
2241
+ }
2242
+ setFeedbackBusy(true)
2243
+ setFeedbackError('')
2244
+ setFeedbackSent('')
2245
+ const nav = typeof navigator !== 'undefined' && navigator !== null ? navigator : {}
2246
+ let timezone = ''
2247
+ try {
2248
+ timezone = typeof Intl === 'object' && Intl !== null && typeof Intl.DateTimeFormat === 'function'
2249
+ ? String(Intl.DateTimeFormat().resolvedOptions().timeZone || '')
2250
+ : ''
2251
+ } catch (err) {
2252
+ timezone = ''
2253
+ }
2254
+ try {
2255
+ const result = await callHost('send-feedback', {
2256
+ type: verdict.value.type,
2257
+ title: verdict.value.title,
2258
+ body: verdict.value.body,
2259
+ contact: verdict.value.contact,
2260
+ locale: typeof nav.language === 'string' ? nav.language : '',
2261
+ userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
2262
+ timezone,
2263
+ }, {
2264
+ timeoutMs: FEEDBACK_SEND_TIMEOUT_MS,
2265
+ timeoutMessage: t('feedback.timeout', { seconds: Math.round(FEEDBACK_SEND_TIMEOUT_MS / 1000) }),
2266
+ })
2267
+ if (result === null || typeof result !== 'object' || result.ok !== true) {
2268
+ const message = result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
2269
+ ? result.message
2270
+ : t('feedback.failed')
2271
+ setFeedbackError(message)
2272
+ console.error('[instruction-memory] feedback failed:', message)
2273
+ return
2274
+ }
2275
+ setFeedbackSent(t('feedback.sent'))
2276
+ setFeedbackType('bug')
2277
+ setFeedbackTitle('')
2278
+ setFeedbackBody('')
2279
+ setFeedbackContact('')
2280
+ } catch (err) {
2281
+ const detail = String(err && err.message ? err.message : err)
2282
+ setFeedbackError(t('feedback.failedDetail', { detail }))
2283
+ console.error('[instruction-memory] feedback request failed:', detail)
2284
+ } finally {
2285
+ setFeedbackBusy(false)
2286
+ }
2287
+ }
2288
+
1880
2289
  // ---- F16 source mode ----
1881
2290
 
1882
2291
  async function openSource() {
@@ -1917,7 +2326,7 @@ window.__ModuleLoader__.load({
1917
2326
  }
1918
2327
 
1919
2328
  function reloadSource() {
1920
- if (typeof window.confirm === 'function' && !window.confirm(t('source.reloadConfirm'))) return
2329
+ if (askConfirm(t('source.reloadConfirm'), 'allow') !== CONFIRM_YES) return
1921
2330
  void openSource()
1922
2331
  }
1923
2332
 
@@ -1950,7 +2359,11 @@ window.__ModuleLoader__.load({
1950
2359
 
1951
2360
  async function handleRestoreFile(file) {
1952
2361
  try {
1953
- if (typeof window.confirm === 'function' && !window.confirm(t('restore.confirm'))) return
2362
+ const askRestore = askConfirm(t('restore.confirm'))
2363
+ if (askRestore !== CONFIRM_YES) {
2364
+ if (askRestore === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2365
+ return
2366
+ }
1954
2367
  const text = typeof file.text === 'function'
1955
2368
  ? await file.text()
1956
2369
  : await new Promise((resolve, reject) => {
@@ -2094,17 +2507,51 @@ window.__ModuleLoader__.load({
2094
2507
  */
2095
2508
  React.useEffect(() => {
2096
2509
  if (typeof EventSource !== 'function') return
2097
- const source = new EventSource(API)
2510
+ // `?lite=1`: the Host then answers with `{ rev, lite: true }` instead
2511
+ // of a whole snapshot (up to ~3.4 MB at the 200 × 6,000-character cap,
2512
+ // pushed to every open panel on every save). A Host that predates the
2513
+ // flag ignores the query string and keeps sending full frames — the
2514
+ // branch below still accepts those, so the negotiation is additive and
2515
+ // one-way: neither side has to know the other's version.
2516
+ const source = new EventSource(API + '?lite=1')
2517
+ let alive = true
2518
+ let refreshing = false
2519
+ let missed = false
2520
+ const refresh = () => {
2521
+ // One refetch at a time. A frame that arrives mid-flight means the
2522
+ // store moved again, and is re-checked after this response lands —
2523
+ // dropping it outright could leave the panel one revision behind.
2524
+ if (refreshing) { missed = true; return }
2525
+ refreshing = true
2526
+ callHost('state', null).then((result) => {
2527
+ refreshing = false
2528
+ if (!alive) return
2529
+ absorb(result)
2530
+ if (missed) { missed = false; refresh() }
2531
+ }, (err) => {
2532
+ refreshing = false
2533
+ if (!alive) return
2534
+ if (missed) { missed = false; refresh() }
2535
+ else if (err) setError(String(err && err.message ? err.message : err))
2536
+ })
2537
+ }
2098
2538
  source.onmessage = (ev) => {
2099
2539
  try {
2100
2540
  const pushed = JSON.parse(ev.data)
2541
+ if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2542
+ const current = viewRef.current
2543
+ // A null view means the initial POST `state` is still in flight;
2544
+ // its response already carries the current rev.
2545
+ if (current !== null && current.data !== null && current.data.rev !== pushed.rev) refresh()
2546
+ return
2547
+ }
2101
2548
  if (sameSnapshot(pushed, viewRef.current)) return
2102
2549
  absorb(pushed)
2103
2550
  } catch {
2104
2551
  // A malformed frame is dropped; the next frame, or 重新载入, catches up.
2105
2552
  }
2106
2553
  }
2107
- return () => { source.close() }
2554
+ return () => { alive = false; source.close() }
2108
2555
  }, [])
2109
2556
 
2110
2557
  if (view === null) {
@@ -2117,6 +2564,10 @@ window.__ModuleLoader__.load({
2117
2564
  h(EntryEditor, {
2118
2565
  draft,
2119
2566
  busy,
2567
+ // `limits` is computed below for the main branch; the editor is an
2568
+ // early return, so read the snapshot directly (fieldLimitsOf falls
2569
+ // back per field for an older Host).
2570
+ limits: view.limits || null,
2120
2571
  onChange: setDraft,
2121
2572
  onCancel: () => {
2122
2573
  if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
@@ -2131,7 +2582,11 @@ window.__ModuleLoader__.load({
2131
2582
  if (result && result.ok === true) { setDraft(null); setError('') }
2132
2583
  })
2133
2584
  },
2134
- }))
2585
+ }),
2586
+ // The editor is an early return, so it must render its OWN error
2587
+ // line: without this, a blank save (or a conflict / failed write)
2588
+ // set the message and showed the user nothing at all.
2589
+ error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null)
2135
2590
  }
2136
2591
 
2137
2592
  const data = view.data
@@ -2142,6 +2597,14 @@ window.__ModuleLoader__.load({
2142
2597
  minBudget: 800, maxBudget: 40000, maxEntries: 200, maxProfiles: 50,
2143
2598
  maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
2144
2599
  }
2600
+ // The feedback ceilings only exist on a Host that ships the feedback
2601
+ // route; an older Host hands over limits without them, so the form falls
2602
+ // back to the same defaults the validator uses.
2603
+ const feedbackMax = {
2604
+ title: typeof limits.maxFeedbackTitle === 'number' ? limits.maxFeedbackTitle : FEEDBACK_DEFAULTS.maxTitle,
2605
+ body: typeof limits.maxFeedbackBody === 'number' ? limits.maxFeedbackBody : FEEDBACK_DEFAULTS.maxBody,
2606
+ contact: typeof limits.maxFeedbackContact === 'number' ? limits.maxFeedbackContact : FEEDBACK_DEFAULTS.maxContact,
2607
+ }
2145
2608
  // Mirrored for the debounced source check (see its effect above): reading
2146
2609
  // it here keeps the effect's dependency list down to the text itself.
2147
2610
  sourceCeilingRef.current = typeof limits.maxBodyChars === 'number' ? limits.maxBodyChars : 0
@@ -2318,9 +2781,91 @@ window.__ModuleLoader__.load({
2318
2781
  void send('set-options', { disabledUntil: 0, baseRev: revOf() })
2319
2782
  }
2320
2783
 
2784
+ // Feedback is the first row of the settings card so it is immediately
2785
+ // visible; source mode keeps its own header and never reaches it.
2786
+ const feedbackCard = h('div', { key: 'feedback', className: 'im-feedback' },
2787
+ h('div', { className: 'im-feedback-head' },
2788
+ h('span', { className: 'im-section-title' }, t('feedback.section')),
2789
+ h('span', { className: 'im-spacer' }),
2790
+ h('button', {
2791
+ className: feedbackOpen ? 'im-btn' : 'im-btn im-btn-primary',
2792
+ disabled: busy,
2793
+ onClick: toggleFeedback,
2794
+ }, feedbackOpen ? t('feedback.hide') : t('feedback.show'))),
2795
+ feedbackOpen
2796
+ ? h('div', { className: 'im-feedback-form' },
2797
+ h('div', { className: 'im-meta' }, t('feedback.intro')),
2798
+ h('div', { className: 'im-grid2' },
2799
+ h('div', null,
2800
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-type' }, t('feedback.typeLabel')),
2801
+ h('select', {
2802
+ id: 'im-feedback-type',
2803
+ className: 'im-select',
2804
+ value: feedbackType,
2805
+ disabled: feedbackBusy,
2806
+ onChange: (ev) => setFeedbackType(String(ev.target.value)),
2807
+ },
2808
+ h('option', { value: 'bug' }, t('feedback.typeBug')),
2809
+ h('option', { value: 'suggestion' }, t('feedback.typeSuggestion')),
2810
+ h('option', { value: 'other' }, t('feedback.typeOther')))),
2811
+ h('div', null,
2812
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-contact' }, t('feedback.contactLabel')),
2813
+ h('input', {
2814
+ id: 'im-feedback-contact',
2815
+ className: 'im-input',
2816
+ type: 'text',
2817
+ value: feedbackContact,
2818
+ maxLength: feedbackMax.contact,
2819
+ placeholder: t('feedback.contactPlaceholder'),
2820
+ disabled: feedbackBusy,
2821
+ onChange: (ev) => setFeedbackContact(String(ev.target.value)),
2822
+ }))),
2823
+ h('div', null,
2824
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-title' }, t('feedback.titleLabel')),
2825
+ h('input', {
2826
+ id: 'im-feedback-title',
2827
+ className: 'im-input',
2828
+ type: 'text',
2829
+ value: feedbackTitle,
2830
+ maxLength: feedbackMax.title,
2831
+ placeholder: t('feedback.titlePlaceholder'),
2832
+ disabled: feedbackBusy,
2833
+ onChange: (ev) => setFeedbackTitle(String(ev.target.value)),
2834
+ })),
2835
+ h('div', null,
2836
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-body' }, t('feedback.bodyLabel')),
2837
+ h('textarea', {
2838
+ id: 'im-feedback-body',
2839
+ className: 'im-textarea',
2840
+ value: feedbackBody,
2841
+ maxLength: feedbackMax.body,
2842
+ placeholder: t('feedback.bodyPlaceholder'),
2843
+ disabled: feedbackBusy,
2844
+ onChange: (ev) => setFeedbackBody(String(ev.target.value)),
2845
+ })),
2846
+ h('div', { className: 'im-meta' }, t('feedback.hint')),
2847
+ // Send failures are shown next to the form and never replace the
2848
+ // panel-wide error line: a mail problem must not look like a
2849
+ // storage problem.
2850
+ feedbackError !== '' ? h('div', { className: 'im-err' }, feedbackError) : null,
2851
+ feedbackSent !== '' ? h('div', { className: 'im-ok' }, feedbackSent) : null,
2852
+ h('div', { className: 'im-feedback-actions' },
2853
+ h('button', {
2854
+ className: 'im-btn im-btn-primary',
2855
+ disabled: feedbackBusy,
2856
+ onClick: () => { void submitFeedback() },
2857
+ }, feedbackBusy ? t('feedback.sending') : t('feedback.send')),
2858
+ h('button', {
2859
+ className: 'im-btn',
2860
+ disabled: feedbackBusy,
2861
+ onClick: clearFeedback,
2862
+ }, t('feedback.clear'))))
2863
+ : null)
2864
+
2321
2865
  const header = h('div', { className: 'im-card', key: 'head' },
2322
- // F12: the non-loopback warning sits at the TOP of the panel — not
2323
- // inside the collapsible diagnostics card — so it cannot be missed.
2866
+ feedbackCard,
2867
+ // F12: the non-loopback warning stays high in the panel — directly
2868
+ // under the feedback entry — so it cannot be missed.
2324
2869
  security.hostGuarded === false
2325
2870
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2326
2871
  t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
@@ -2362,8 +2907,9 @@ window.__ModuleLoader__.load({
2362
2907
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
2363
2908
  h('div', { className: 'im-grid2' },
2364
2909
  h('div', null,
2365
- h('label', { className: 'im-label' }, t('budget.label')),
2910
+ h('label', { className: 'im-label', htmlFor: 'im-budget' }, t('budget.label')),
2366
2911
  h('input', {
2912
+ id: 'im-budget',
2367
2913
  className: 'im-input',
2368
2914
  type: 'number',
2369
2915
  min: limits.minBudget,
@@ -2455,8 +3001,9 @@ window.__ModuleLoader__.load({
2455
3001
  }, t('sets.delete')))),
2456
3002
  setForm !== null
2457
3003
  ? h('div', { className: 'im-inline-form' },
2458
- h('span', { className: 'im-meta' }, t('sets.nameLabel')),
3004
+ h('label', { className: 'im-meta', htmlFor: 'im-set-name' }, t('sets.nameLabel')),
2459
3005
  h('input', {
3006
+ id: 'im-set-name',
2460
3007
  className: 'im-input',
2461
3008
  value: setForm.name,
2462
3009
  maxLength: limits.maxSetName,
@@ -2572,6 +3119,39 @@ window.__ModuleLoader__.load({
2572
3119
  }, t('batch.delete')),
2573
3120
  h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
2574
3121
  : null,
3122
+ // Browser runtimes get window.prompt for the bulk tag text; Electron's
3123
+ // prompt throws instead, so the same action falls back to this field.
3124
+ (batchMode && batchTagOpen)
3125
+ ? h('div', { key: 'batchtags', className: 'im-batch-tags' },
3126
+ h('input', {
3127
+ className: 'im-input',
3128
+ type: 'text',
3129
+ autoFocus: true,
3130
+ value: batchTagText,
3131
+ disabled: busy,
3132
+ placeholder: t('batch.tagInputPlaceholder'),
3133
+ onChange: (ev) => setBatchTagText(ev.target.value),
3134
+ onKeyDown: (ev) => {
3135
+ if (ev.key === 'Enter') {
3136
+ ev.preventDefault()
3137
+ if (commitBatchTags(batchTagText)) closeBatchTags()
3138
+ } else if (ev.key === 'Escape') {
3139
+ ev.preventDefault()
3140
+ closeBatchTags()
3141
+ }
3142
+ },
3143
+ }),
3144
+ h('button', {
3145
+ className: 'im-btn im-btn-primary',
3146
+ disabled: busy,
3147
+ onClick: () => { if (commitBatchTags(batchTagText)) closeBatchTags() },
3148
+ }, t('batch.tagConfirm')),
3149
+ h('button', {
3150
+ className: 'im-btn',
3151
+ disabled: busy,
3152
+ onClick: () => { closeBatchTags() },
3153
+ }, t('batch.tagCancel')))
3154
+ : null,
2575
3155
  // ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
2576
3156
  (data.enabled === true && autoEntries.length > 0)
2577
3157
  ? h('div', { key: 'sim', className: 'im-profiles' },
@@ -2710,7 +3290,7 @@ window.__ModuleLoader__.load({
2710
3290
  h('button', {
2711
3291
  className: 'im-btn',
2712
3292
  disabled: busy || entries.length === 0,
2713
- onClick: () => { setBatchMode(!batchMode); setSelected([]) },
3293
+ onClick: () => { setBatchMode(!batchMode); setSelected([]); closeBatchTags() },
2714
3294
  }, batchMode ? t('batch.exit') : t('action.batch')),
2715
3295
  h('span', { className: 'im-spacer' }),
2716
3296
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
@@ -2726,9 +3306,10 @@ window.__ModuleLoader__.load({
2726
3306
  // ---- F18 备份位置 ----
2727
3307
  h('div', { key: 'backup', className: 'im-backup' },
2728
3308
  h('div', { className: 'im-section-title' }, t('backup.section')),
2729
- h('label', { className: 'im-label' }, t('backup.label')),
3309
+ h('label', { className: 'im-label', htmlFor: 'im-backup-dir' }, t('backup.label')),
2730
3310
  h('div', { className: 'im-backup-row' },
2731
3311
  h('input', {
3312
+ id: 'im-backup-dir',
2732
3313
  className: 'im-input',
2733
3314
  value: backupText,
2734
3315
  disabled: busy,
@@ -2833,9 +3414,9 @@ window.__ModuleLoader__.load({
2833
3414
  }))
2834
3415
  : null)
2835
3416
  : null),
2836
- error !== '' ? h('div', { className: 'im-err' }, error) : null,
2837
- error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
2838
- error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
3417
+ error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
3418
+ error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
3419
+ error === '' && notice === '' && storage.error ? h('div', { className: 'im-err', role: 'alert' }, storage.error) : null,
2839
3420
  undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
2840
3421
  h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
2841
3422
  h('button', { disabled: busy, onClick: () => {
@@ -2913,12 +3494,12 @@ window.__ModuleLoader__.load({
2913
3494
  sourceVerdict !== null
2914
3495
  ? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
2915
3496
  : null,
2916
- error !== '' ? h('div', { className: 'im-err' }, error) : null,
3497
+ error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
2917
3498
  // Saving can be refused (a conflict, a future version, a shape
2918
3499
  // problem). The draft is never thrown away by a refusal, and the
2919
3500
  // user needs to know that before deciding what to do next.
2920
3501
  error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
2921
- error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
3502
+ error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
2922
3503
  h('div', { className: 'im-row-wrap' },
2923
3504
  h('button', {
2924
3505
  className: 'im-btn im-btn-primary',
@@ -3008,12 +3589,17 @@ window.__ModuleLoader__.load({
3008
3589
  className: 'im-btn im-btn-danger',
3009
3590
  disabled: busy,
3010
3591
  onClick: () => {
3011
- // confirm() is standard DOM, guarded anyway: if the embedded
3012
- // shell omits it, deletion proceeds rather than breaking.
3013
- if (typeof window.confirm === 'function'
3014
- && !window.confirm(t('entry.confirmDelete', {
3015
- title: entry.title !== '' ? entry.title : t('entry.untitled'),
3016
- }))) return
3592
+ // Durable-data delete: if this shell has no working dialog,
3593
+ // the delete is refused and the reason is shown — silently
3594
+ // dropping an instruction because a button was clickable
3595
+ // and the shell lacked confirm() is the worse failure.
3596
+ const askEntry = askConfirm(t('entry.confirmDelete', {
3597
+ title: entry.title !== '' ? entry.title : t('entry.untitled'),
3598
+ }))
3599
+ if (askEntry !== CONFIRM_YES) {
3600
+ if (askEntry === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
3601
+ return
3602
+ }
3017
3603
  // Keep the full entry for the undo bar; the POST response
3018
3604
  // (not this closure) is what confirms the deletion happened.
3019
3605
  setUndo(null)
@@ -3133,13 +3719,16 @@ window.__ModuleLoader__.load({
3133
3719
  exports.__draftIsDirty = draftIsDirty
3134
3720
  exports.__sameSnapshot = sameSnapshot
3135
3721
  exports.__confirmDiscard = confirmDiscard
3722
+ exports.__askConfirm = askConfirm
3136
3723
  exports.__importSizeVerdict = importSizeVerdict
3137
3724
  exports.__nextBudgetText = nextBudgetText
3138
3725
  exports.__budgetMeter = budgetMeter
3139
3726
  exports.__parseTagInput = parseTagInput
3727
+ exports.__batchTagOutcome = batchTagOutcome
3140
3728
  exports.__relevanceTerms = relevanceTerms
3141
3729
  exports.__relevancePreview = relevancePreview
3142
3730
  exports.__sourceParseVerdict = sourceParseVerdict
3731
+ exports.__feedbackVerdict = feedbackVerdict
3143
3732
  exports.__diagnosticsText = diagnosticsText
3144
3733
  exports.__strings = STRINGS
3145
3734
  exports.__ns = NS