dsh-instruction-memory 1.4.0 → 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,12 @@ 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
+
25
31
  // Feedback form: the kinds are a closed set shared with the Host (see
26
32
  // lib/mailer.js); the ceilings repeat the Host defaults so an older Host
27
33
  // that does not publish limits.maxFeedback* still renders a safe form.
@@ -38,6 +44,14 @@ window.__ModuleLoader__.load({
38
44
  .im-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
39
45
  .im-row-wrap { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
40
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
+ }
41
55
  .im-spacer { flex:1; }
42
56
  .im-btn { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
43
57
  color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; }
@@ -89,6 +103,9 @@ window.__ModuleLoader__.load({
89
103
  .im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
90
104
  border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
91
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; }
92
109
  .im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
93
110
  background:var(--dsw-alias-bg-layer-1,#fff); }
94
111
  .im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
@@ -117,7 +134,9 @@ window.__ModuleLoader__.load({
117
134
  .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
118
135
  .im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
119
136
  .im-body-open { max-height:none; }
120
- .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; }
121
140
  .im-search-meta { margin-top:5px; }
122
141
  .im-meter-line { margin-top:5px; }
123
142
  .im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
@@ -240,6 +259,7 @@ window.__ModuleLoader__.load({
240
259
  'editor.hint': '保存后对下一轮对话立即生效',
241
260
  'editor.needTitleOrContent': '请至少填写标题或指令内容',
242
261
  'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
262
+ 'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
243
263
  'body.expand': '展开全部',
244
264
  'body.collapse': '收起',
245
265
  'budget.label': '注入字数上限(字符)',
@@ -259,6 +279,7 @@ window.__ModuleLoader__.load({
259
279
  'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
260
280
  'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
261
281
  'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
282
+ 'tags.remove': '移除标签 {tag}',
262
283
  'tags.duplicate': '已有同名标签',
263
284
  'tags.full': '每条最多 {max} 个标签',
264
285
  'filter.all': '全部',
@@ -286,7 +307,9 @@ window.__ModuleLoader__.load({
286
307
  'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
287
308
  'batch.tagEmpty': '未输入有效的标签',
288
309
  'batch.noneSelected': '请先勾选要操作的指令',
289
- 'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
310
+ 'batch.tagInputPlaceholder': '输入要添加的标签(多个用逗号分隔)',
311
+ 'batch.tagConfirm': '添加',
312
+ 'batch.tagCancel': '取消',
290
313
  'profiles.section': '用户形象',
291
314
  'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
292
315
  'profiles.new': '+ 新增形象',
@@ -407,6 +430,7 @@ window.__ModuleLoader__.load({
407
430
  'feedback.sent': '反馈已发送,感谢你的反馈!',
408
431
  'feedback.failed': '反馈发送失败,请稍后重试。',
409
432
  'feedback.failedDetail': '反馈发送失败:{detail}',
433
+ 'feedback.timeout': '等待宿主响应超时({seconds} 秒)。邮件可能已经发出,请先确认再重试。',
410
434
  'feedback.errType': '请选择有效的问题类型',
411
435
  'feedback.errTitle': '请填写标题',
412
436
  'feedback.errTitleLong': '标题不能超过 {max} 个字符',
@@ -519,6 +543,7 @@ window.__ModuleLoader__.load({
519
543
  'editor.hint': 'Takes effect for the next conversation turn once saved',
520
544
  'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
521
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.',
522
547
  'body.expand': 'Show all',
523
548
  'body.collapse': 'Show less',
524
549
  'budget.label': 'Injection size limit (characters)',
@@ -538,6 +563,7 @@ window.__ModuleLoader__.load({
538
563
  'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
539
564
  'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
540
565
  'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
566
+ 'tags.remove': 'Remove tag {tag}',
541
567
  'tags.duplicate': 'This tag already exists',
542
568
  'tags.full': 'Up to {max} tags per entry',
543
569
  'filter.all': 'All',
@@ -565,7 +591,9 @@ window.__ModuleLoader__.load({
565
591
  'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
566
592
  'batch.tagEmpty': 'No valid tag entered',
567
593
  'batch.noneSelected': 'Tick the entries to act on first',
568
- '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',
569
597
  'profiles.section': 'User persona',
570
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.',
571
599
  'profiles.new': '+ New persona',
@@ -686,6 +714,7 @@ window.__ModuleLoader__.load({
686
714
  'feedback.sent': 'Feedback sent — thank you!',
687
715
  'feedback.failed': 'Could not send the feedback. Please try again later.',
688
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.',
689
718
  'feedback.errType': 'Choose a valid category',
690
719
  'feedback.errTitle': 'Enter a title',
691
720
  'feedback.errTitleLong': 'The title must be at most {max} characters',
@@ -887,32 +916,95 @@ window.__ModuleLoader__.load({
887
916
 
888
917
  /* ---------------- data helpers ---------------- */
889
918
 
890
- // Mirror the Host's tag limits (lib/index.js); kept local so the editor
891
- // 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.
892
922
  const MAX_TAGS = 8
893
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
+ }
894
950
 
895
951
  /**
896
952
  * Parse free text typed into the tag box into normalised tag candidates.
897
953
  * Tags are split on commas (ASCII/full-width), semicolons and newlines —
898
954
  * never spaces, so a tag may contain spaces. Each piece is trimmed,
899
- * collapsed to one line, cut to MAX_TAG_LEN, de-duplicated and capped at
900
- * 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.
901
958
  */
902
- function parseTagInput(text) {
959
+ function parseTagInput(text, limits) {
903
960
  if (typeof text !== 'string') return []
961
+ const caps = fieldLimitsOf(limits)
904
962
  const out = []
905
963
  const seen = new Set()
906
964
  for (const piece of text.split(/[,,;;\n\r]+/)) {
907
- const tag = piece.replace(/\s+/g, ' ').trim().slice(0, MAX_TAG_LEN)
965
+ const tag = piece.replace(/\s+/g, ' ').trim().slice(0, caps.maxTagLength)
908
966
  if (tag === '' || seen.has(tag)) continue
909
967
  seen.add(tag)
910
- if (out.length >= MAX_TAGS) break
968
+ if (out.length >= caps.maxTags) break
911
969
  out.push(tag)
912
970
  }
913
971
  return out
914
972
  }
915
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
+
916
1008
  function cloneEntry(entry) {
917
1009
  return {
918
1010
  id: entry.id,
@@ -977,15 +1069,58 @@ window.__ModuleLoader__.load({
977
1069
  return tagsKey(draft) !== tagsKey(base)
978
1070
  }
979
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
+
980
1116
  /**
981
1117
  * Ask before throwing the draft away. Falls back to "yes" when no dialog
982
1118
  * is available (a bare webview without window.confirm): blocking the
983
- * 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.
984
1121
  */
985
1122
  function confirmDiscard() {
986
- return typeof window !== 'undefined' && typeof window.confirm === 'function'
987
- ? window.confirm(t('editor.confirmDiscard'))
988
- : true
1123
+ return askConfirm(t('editor.confirmDiscard'), 'allow') === CONFIRM_YES
989
1124
  }
990
1125
 
991
1126
  /**
@@ -1316,11 +1451,14 @@ window.__ModuleLoader__.load({
1316
1451
  }
1317
1452
  }
1318
1453
 
1319
- async function callHost(method, args) {
1454
+ async function callHost(method, args, options) {
1320
1455
  // A hung host used to pin the panel on 保存中… forever; fail visibly
1321
- // 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
1322
1460
  const controller = typeof AbortController === 'function' ? new AbortController() : null
1323
- const timer = controller !== null ? setTimeout(() => controller.abort(), 15000) : null
1461
+ const timer = controller !== null ? setTimeout(() => controller.abort(), timeoutMs) : null
1324
1462
  try {
1325
1463
  const response = await fetch(API, {
1326
1464
  method: 'POST',
@@ -1345,7 +1483,11 @@ window.__ModuleLoader__.load({
1345
1483
  }
1346
1484
  return await response.json()
1347
1485
  } catch (err) {
1348
- 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
+ }
1349
1491
  throw err
1350
1492
  } finally {
1351
1493
  if (timer !== null) clearTimeout(timer)
@@ -1384,31 +1526,35 @@ window.__ModuleLoader__.load({
1384
1526
 
1385
1527
  function EntryEditor(props) {
1386
1528
  const draft = props.draft
1529
+ const caps = fieldLimitsOf(props.limits)
1387
1530
  const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
1388
1531
  const children = [
1389
1532
  h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? t('editor.new') : t('editor.edit')),
1390
1533
  h('div', { key: 't' },
1391
- h('label', { className: 'im-label' }, t('editor.title')),
1534
+ h('label', { className: 'im-label', htmlFor: 'im-entry-title' }, t('editor.title')),
1392
1535
  h('input', {
1536
+ id: 'im-entry-title',
1393
1537
  className: 'im-input',
1394
1538
  value: draft.title,
1395
- maxLength: 120,
1539
+ maxLength: caps.maxTitle,
1396
1540
  placeholder: t('editor.titlePlaceholder'),
1397
1541
  onChange: (ev) => set({ title: ev.target.value }),
1398
1542
  })),
1399
1543
  h('div', { key: 'c' },
1400
- h('label', { className: 'im-label' }, t('editor.content')),
1544
+ h('label', { className: 'im-label', htmlFor: 'im-entry-content' }, t('editor.content')),
1401
1545
  h('textarea', {
1546
+ id: 'im-entry-content',
1402
1547
  className: 'im-textarea',
1403
1548
  value: draft.content,
1404
- maxLength: 6000,
1549
+ maxLength: caps.maxContent,
1405
1550
  placeholder: t('editor.contentPlaceholder'),
1406
1551
  onChange: (ev) => set({ content: ev.target.value }),
1407
1552
  })),
1408
1553
  h('div', { className: 'im-grid2', key: 'g' },
1409
1554
  h('div', null,
1410
- h('label', { className: 'im-label' }, t('editor.mode')),
1555
+ h('label', { className: 'im-label', htmlFor: 'im-entry-mode' }, t('editor.mode')),
1411
1556
  h('select', {
1557
+ id: 'im-entry-mode',
1412
1558
  className: 'im-select',
1413
1559
  value: draft.mode,
1414
1560
  onChange: (ev) => set({ mode: ev.target.value }),
@@ -1416,8 +1562,9 @@ window.__ModuleLoader__.load({
1416
1562
  h('option', { value: 'always' }, t('editor.modeAlways')),
1417
1563
  h('option', { value: 'auto' }, t('editor.modeAuto')))),
1418
1564
  h('div', null,
1419
- h('label', { className: 'im-label' }, t('editor.priority')),
1565
+ h('label', { className: 'im-label', htmlFor: 'im-entry-priority' }, t('editor.priority')),
1420
1566
  h('select', {
1567
+ id: 'im-entry-priority',
1421
1568
  className: 'im-select',
1422
1569
  value: String(draft.priority),
1423
1570
  onChange: (ev) => set({ priority: Number(ev.target.value) }),
@@ -1428,19 +1575,22 @@ window.__ModuleLoader__.load({
1428
1575
  ]
1429
1576
  if (draft.mode === 'auto') {
1430
1577
  children.push(h('div', { key: 'w' },
1431
- h('label', { className: 'im-label' }, t('editor.when')),
1578
+ h('label', { className: 'im-label', htmlFor: 'im-entry-when' }, t('editor.when')),
1432
1579
  h('input', {
1580
+ id: 'im-entry-when',
1433
1581
  className: 'im-input',
1434
1582
  value: draft.when,
1435
- maxLength: 200,
1583
+ maxLength: caps.maxWhen,
1436
1584
  placeholder: t('editor.whenPlaceholder'),
1437
1585
  onChange: (ev) => set({ when: ev.target.value }),
1438
1586
  })))
1439
1587
  }
1440
1588
  children.push(h('div', { key: 'tags' },
1441
- h('label', { className: 'im-label' }, t('tags.label')),
1589
+ h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
1442
1590
  h(TagInput, {
1591
+ inputId: 'im-entry-tags',
1443
1592
  tags: draft.tags,
1593
+ limits: props.limits,
1444
1594
  disabled: props.busy,
1445
1595
  onChange: (next) => set({ tags: next }),
1446
1596
  })))
@@ -1462,15 +1612,16 @@ window.__ModuleLoader__.load({
1462
1612
  function TagInput(props) {
1463
1613
  const [text, setText] = React.useState('')
1464
1614
  const tags = Array.isArray(props.tags) ? props.tags : []
1615
+ const caps = fieldLimitsOf(props.limits)
1465
1616
  const merge = (pieces) => {
1466
1617
  const merged = []
1467
1618
  for (const tag of [...tags, ...pieces]) {
1468
1619
  if (!merged.includes(tag)) merged.push(tag)
1469
1620
  }
1470
- props.onChange(merged.slice(0, MAX_TAGS))
1621
+ props.onChange(merged.slice(0, caps.maxTags))
1471
1622
  }
1472
1623
  const commit = () => {
1473
- const parsed = parseTagInput(text)
1624
+ const parsed = parseTagInput(text, props.limits)
1474
1625
  if (parsed.length > 0) merge(parsed)
1475
1626
  setText('')
1476
1627
  }
@@ -1484,14 +1635,19 @@ window.__ModuleLoader__.load({
1484
1635
  tag,
1485
1636
  h('button', {
1486
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 }),
1487
1642
  onClick: () => remove(tag),
1488
1643
  }, '×'))))
1489
1644
  : null,
1490
1645
  h('input', {
1646
+ id: props.inputId,
1491
1647
  className: 'im-input',
1492
1648
  value: text,
1493
1649
  disabled: props.disabled,
1494
- maxLength: MAX_TAG_LEN,
1650
+ maxLength: caps.maxTagLength,
1495
1651
  placeholder: t('tags.placeholder'),
1496
1652
  onChange: (ev) => {
1497
1653
  const v = ev.target.value
@@ -1500,11 +1656,11 @@ window.__ModuleLoader__.load({
1500
1656
  // v is the input's full new value (it already contains what was
1501
1657
  // typed before the separator) — do NOT prepend `text`, that
1502
1658
  // duplicated it ("b" + "b," -> "bb").
1503
- const parsed = parseTagInput(v)
1659
+ const parsed = parseTagInput(v, props.limits)
1504
1660
  if (parsed.length > 0) merge(parsed)
1505
1661
  setText('')
1506
1662
  } else {
1507
- setText(v.slice(0, MAX_TAG_LEN))
1663
+ setText(v.slice(0, caps.maxTagLength))
1508
1664
  }
1509
1665
  },
1510
1666
  onKeyDown: (ev) => {
@@ -1517,7 +1673,7 @@ window.__ModuleLoader__.load({
1517
1673
  },
1518
1674
  onBlur: commit,
1519
1675
  }),
1520
- 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 })))
1521
1677
  }
1522
1678
 
1523
1679
  /**
@@ -1535,7 +1691,12 @@ window.__ModuleLoader__.load({
1535
1691
  }, [props.text])
1536
1692
  return h('div', null,
1537
1693
  h('div', { className: 'im-body' + (open ? ' im-body-open' : ''), ref: bodyRef }, props.text),
1538
- 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)
1539
1700
  }
1540
1701
 
1541
1702
  /** How long the undo bar keeps its offer alive after a confirmed delete. */
@@ -1556,8 +1717,9 @@ window.__ModuleLoader__.load({
1556
1717
  const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
1557
1718
  return h('div', { className: 'im-editor im-profiles' },
1558
1719
  h('div', null,
1559
- h('label', { className: 'im-label' }, t('profiles.name')),
1720
+ h('label', { className: 'im-label', htmlFor: 'im-profile-name' }, t('profiles.name')),
1560
1721
  h('input', {
1722
+ id: 'im-profile-name',
1561
1723
  className: 'im-input',
1562
1724
  value: props.draft.name,
1563
1725
  maxLength: maxName,
@@ -1566,8 +1728,9 @@ window.__ModuleLoader__.load({
1566
1728
  onChange: (ev) => set({ name: ev.target.value }),
1567
1729
  })),
1568
1730
  h('div', null,
1569
- h('label', { className: 'im-label' }, t('profiles.content')),
1731
+ h('label', { className: 'im-label', htmlFor: 'im-profile-content' }, t('profiles.content')),
1570
1732
  h('textarea', {
1733
+ id: 'im-profile-content',
1571
1734
  className: 'im-textarea',
1572
1735
  rows: 6,
1573
1736
  value: props.draft.content,
@@ -1621,6 +1784,13 @@ window.__ModuleLoader__.load({
1621
1784
  // F3: bulk-select mode and the chosen entry ids in it.
1622
1785
  const [batchMode, setBatchMode] = React.useState(false)
1623
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('')
1624
1794
  // F13: the set form (null = closed). One small inline form serves create,
1625
1795
  // rename and duplicate, because all three are "type a name and confirm".
1626
1796
  const [setForm, setSetForm] = React.useState(null)
@@ -1819,6 +1989,12 @@ window.__ModuleLoader__.load({
1819
1989
  function exitBatch() {
1820
1990
  setBatchMode(false)
1821
1991
  setSelected([])
1992
+ closeBatchTags()
1993
+ }
1994
+
1995
+ function closeBatchTags() {
1996
+ setBatchTagOpen(false)
1997
+ setBatchTagText('')
1822
1998
  }
1823
1999
 
1824
2000
  async function doBatch(op, value) {
@@ -1827,10 +2003,12 @@ window.__ModuleLoader__.load({
1827
2003
  setError(t('batch.noneSelected'))
1828
2004
  return
1829
2005
  }
1830
- if (op === 'delete'
1831
- && typeof window.confirm === 'function'
1832
- && !window.confirm(t('batch.confirmDelete', { count: ids.length }))) {
1833
- 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
+ }
1834
2012
  }
1835
2013
  const result = await send('batch', { op, ids, value, baseRev: revOf() })
1836
2014
  if (result !== null && result.ok === true && op === 'delete') {
@@ -1840,19 +2018,41 @@ window.__ModuleLoader__.load({
1840
2018
  }
1841
2019
  }
1842
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
+ */
1843
2039
  function batchAddTags() {
1844
- if (typeof window.prompt !== 'function') {
1845
- setError(t('batch.promptUnavailable'))
2040
+ const ids = liveSelected()
2041
+ if (ids.length === 0) {
2042
+ setError(t('batch.noneSelected'))
1846
2043
  return
1847
2044
  }
1848
- const answer = window.prompt(t('batch.tagPrompt', { count: liveSelected().length }), '')
1849
- if (answer === null) return
1850
- const tags = parseTagInput(answer)
1851
- if (tags.length === 0) {
1852
- 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()
1853
2052
  return
1854
2053
  }
1855
- void doBatch('add-tags', tags)
2054
+ setError('')
2055
+ setBatchTagOpen(true)
1856
2056
  }
1857
2057
 
1858
2058
  // ---- User persona ("用户形象") handlers ----
@@ -1881,10 +2081,13 @@ window.__ModuleLoader__.load({
1881
2081
 
1882
2082
  function removeProfile(profile) {
1883
2083
  if (busy) return
1884
- if (typeof window.confirm === 'function'
1885
- && !window.confirm(t('profiles.confirmDelete', {
1886
- name: profile.name !== '' ? profile.name : t('profiles.untitled'),
1887
- }))) 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
+ }
1888
2091
  void send('delete-profile', { id: profile.id, baseRev: revOf() })
1889
2092
  }
1890
2093
 
@@ -1942,8 +2145,11 @@ window.__ModuleLoader__.load({
1942
2145
  setError(t('sets.deleteActive'))
1943
2146
  return
1944
2147
  }
1945
- if (typeof window.confirm === 'function'
1946
- && !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
+ }
1947
2153
  void send('delete-set', { id: set.id, baseRev: revOf() })
1948
2154
  }
1949
2155
 
@@ -1975,9 +2181,13 @@ window.__ModuleLoader__.load({
1975
2181
  }
1976
2182
 
1977
2183
  function rollbackTo(item) {
1978
- if (busy || typeof window.confirm !== 'function') return
2184
+ if (busy) return
1979
2185
  const when = new Date(item.epoch).toLocaleString()
1980
- 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
+ }
1981
2191
  void send('history-restore', { name: item.name, baseRev: revOf() }).then((result) => {
1982
2192
  if (result !== null && result.ok === true) void loadHistory()
1983
2193
  })
@@ -1985,7 +2195,11 @@ window.__ModuleLoader__.load({
1985
2195
 
1986
2196
  function removeSnapshot(item) {
1987
2197
  if (busy) return
1988
- 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
+ }
1989
2203
  void send('history-delete', { name: item.name }).then((result) => {
1990
2204
  if (result !== null && result.ok === true) void loadHistory()
1991
2205
  })
@@ -2046,6 +2260,9 @@ window.__ModuleLoader__.load({
2046
2260
  locale: typeof nav.language === 'string' ? nav.language : '',
2047
2261
  userAgent: typeof nav.userAgent === 'string' ? nav.userAgent : '',
2048
2262
  timezone,
2263
+ }, {
2264
+ timeoutMs: FEEDBACK_SEND_TIMEOUT_MS,
2265
+ timeoutMessage: t('feedback.timeout', { seconds: Math.round(FEEDBACK_SEND_TIMEOUT_MS / 1000) }),
2049
2266
  })
2050
2267
  if (result === null || typeof result !== 'object' || result.ok !== true) {
2051
2268
  const message = result !== null && typeof result === 'object' && typeof result.message === 'string' && result.message !== ''
@@ -2109,7 +2326,7 @@ window.__ModuleLoader__.load({
2109
2326
  }
2110
2327
 
2111
2328
  function reloadSource() {
2112
- if (typeof window.confirm === 'function' && !window.confirm(t('source.reloadConfirm'))) return
2329
+ if (askConfirm(t('source.reloadConfirm'), 'allow') !== CONFIRM_YES) return
2113
2330
  void openSource()
2114
2331
  }
2115
2332
 
@@ -2142,7 +2359,11 @@ window.__ModuleLoader__.load({
2142
2359
 
2143
2360
  async function handleRestoreFile(file) {
2144
2361
  try {
2145
- 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
+ }
2146
2367
  const text = typeof file.text === 'function'
2147
2368
  ? await file.text()
2148
2369
  : await new Promise((resolve, reject) => {
@@ -2286,17 +2507,51 @@ window.__ModuleLoader__.load({
2286
2507
  */
2287
2508
  React.useEffect(() => {
2288
2509
  if (typeof EventSource !== 'function') return
2289
- 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
+ }
2290
2538
  source.onmessage = (ev) => {
2291
2539
  try {
2292
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
+ }
2293
2548
  if (sameSnapshot(pushed, viewRef.current)) return
2294
2549
  absorb(pushed)
2295
2550
  } catch {
2296
2551
  // A malformed frame is dropped; the next frame, or 重新载入, catches up.
2297
2552
  }
2298
2553
  }
2299
- return () => { source.close() }
2554
+ return () => { alive = false; source.close() }
2300
2555
  }, [])
2301
2556
 
2302
2557
  if (view === null) {
@@ -2309,6 +2564,10 @@ window.__ModuleLoader__.load({
2309
2564
  h(EntryEditor, {
2310
2565
  draft,
2311
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,
2312
2571
  onChange: setDraft,
2313
2572
  onCancel: () => {
2314
2573
  if (draftIsDirty(draft, view.data.entries) && !confirmDiscard()) return
@@ -2323,7 +2582,11 @@ window.__ModuleLoader__.load({
2323
2582
  if (result && result.ok === true) { setDraft(null); setError('') }
2324
2583
  })
2325
2584
  },
2326
- }))
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)
2327
2590
  }
2328
2591
 
2329
2592
  const data = view.data
@@ -2534,8 +2797,9 @@ window.__ModuleLoader__.load({
2534
2797
  h('div', { className: 'im-meta' }, t('feedback.intro')),
2535
2798
  h('div', { className: 'im-grid2' },
2536
2799
  h('div', null,
2537
- h('label', { className: 'im-label' }, t('feedback.typeLabel')),
2800
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-type' }, t('feedback.typeLabel')),
2538
2801
  h('select', {
2802
+ id: 'im-feedback-type',
2539
2803
  className: 'im-select',
2540
2804
  value: feedbackType,
2541
2805
  disabled: feedbackBusy,
@@ -2545,8 +2809,9 @@ window.__ModuleLoader__.load({
2545
2809
  h('option', { value: 'suggestion' }, t('feedback.typeSuggestion')),
2546
2810
  h('option', { value: 'other' }, t('feedback.typeOther')))),
2547
2811
  h('div', null,
2548
- h('label', { className: 'im-label' }, t('feedback.contactLabel')),
2812
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-contact' }, t('feedback.contactLabel')),
2549
2813
  h('input', {
2814
+ id: 'im-feedback-contact',
2550
2815
  className: 'im-input',
2551
2816
  type: 'text',
2552
2817
  value: feedbackContact,
@@ -2556,8 +2821,9 @@ window.__ModuleLoader__.load({
2556
2821
  onChange: (ev) => setFeedbackContact(String(ev.target.value)),
2557
2822
  }))),
2558
2823
  h('div', null,
2559
- h('label', { className: 'im-label' }, t('feedback.titleLabel')),
2824
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-title' }, t('feedback.titleLabel')),
2560
2825
  h('input', {
2826
+ id: 'im-feedback-title',
2561
2827
  className: 'im-input',
2562
2828
  type: 'text',
2563
2829
  value: feedbackTitle,
@@ -2567,8 +2833,9 @@ window.__ModuleLoader__.load({
2567
2833
  onChange: (ev) => setFeedbackTitle(String(ev.target.value)),
2568
2834
  })),
2569
2835
  h('div', null,
2570
- h('label', { className: 'im-label' }, t('feedback.bodyLabel')),
2836
+ h('label', { className: 'im-label', htmlFor: 'im-feedback-body' }, t('feedback.bodyLabel')),
2571
2837
  h('textarea', {
2838
+ id: 'im-feedback-body',
2572
2839
  className: 'im-textarea',
2573
2840
  value: feedbackBody,
2574
2841
  maxLength: feedbackMax.body,
@@ -2640,8 +2907,9 @@ window.__ModuleLoader__.load({
2640
2907
  h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
2641
2908
  h('div', { className: 'im-grid2' },
2642
2909
  h('div', null,
2643
- h('label', { className: 'im-label' }, t('budget.label')),
2910
+ h('label', { className: 'im-label', htmlFor: 'im-budget' }, t('budget.label')),
2644
2911
  h('input', {
2912
+ id: 'im-budget',
2645
2913
  className: 'im-input',
2646
2914
  type: 'number',
2647
2915
  min: limits.minBudget,
@@ -2733,8 +3001,9 @@ window.__ModuleLoader__.load({
2733
3001
  }, t('sets.delete')))),
2734
3002
  setForm !== null
2735
3003
  ? h('div', { className: 'im-inline-form' },
2736
- h('span', { className: 'im-meta' }, t('sets.nameLabel')),
3004
+ h('label', { className: 'im-meta', htmlFor: 'im-set-name' }, t('sets.nameLabel')),
2737
3005
  h('input', {
3006
+ id: 'im-set-name',
2738
3007
  className: 'im-input',
2739
3008
  value: setForm.name,
2740
3009
  maxLength: limits.maxSetName,
@@ -2850,6 +3119,39 @@ window.__ModuleLoader__.load({
2850
3119
  }, t('batch.delete')),
2851
3120
  h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
2852
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,
2853
3155
  // ---- F17 本地预演:字面关键词匹配「按需」条目(不是模型真实判断) ----
2854
3156
  (data.enabled === true && autoEntries.length > 0)
2855
3157
  ? h('div', { key: 'sim', className: 'im-profiles' },
@@ -2988,7 +3290,7 @@ window.__ModuleLoader__.load({
2988
3290
  h('button', {
2989
3291
  className: 'im-btn',
2990
3292
  disabled: busy || entries.length === 0,
2991
- onClick: () => { setBatchMode(!batchMode); setSelected([]) },
3293
+ onClick: () => { setBatchMode(!batchMode); setSelected([]); closeBatchTags() },
2992
3294
  }, batchMode ? t('batch.exit') : t('action.batch')),
2993
3295
  h('span', { className: 'im-spacer' }),
2994
3296
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
@@ -3004,9 +3306,10 @@ window.__ModuleLoader__.load({
3004
3306
  // ---- F18 备份位置 ----
3005
3307
  h('div', { key: 'backup', className: 'im-backup' },
3006
3308
  h('div', { className: 'im-section-title' }, t('backup.section')),
3007
- h('label', { className: 'im-label' }, t('backup.label')),
3309
+ h('label', { className: 'im-label', htmlFor: 'im-backup-dir' }, t('backup.label')),
3008
3310
  h('div', { className: 'im-backup-row' },
3009
3311
  h('input', {
3312
+ id: 'im-backup-dir',
3010
3313
  className: 'im-input',
3011
3314
  value: backupText,
3012
3315
  disabled: busy,
@@ -3111,9 +3414,9 @@ window.__ModuleLoader__.load({
3111
3414
  }))
3112
3415
  : null)
3113
3416
  : null),
3114
- error !== '' ? h('div', { className: 'im-err' }, error) : null,
3115
- error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
3116
- 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,
3117
3420
  undo !== null ? h('div', { className: 'im-undo', key: 'undo' },
3118
3421
  h('span', null, t('undo.deleted', { title: undo.entry.title !== '' ? undo.entry.title : t('entry.untitled') })),
3119
3422
  h('button', { disabled: busy, onClick: () => {
@@ -3191,12 +3494,12 @@ window.__ModuleLoader__.load({
3191
3494
  sourceVerdict !== null
3192
3495
  ? h('div', { className: sourceVerdict.ok ? 'im-source-ok' : 'im-warn' }, sourceVerdict.message)
3193
3496
  : null,
3194
- error !== '' ? h('div', { className: 'im-err' }, error) : null,
3497
+ error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
3195
3498
  // Saving can be refused (a conflict, a future version, a shape
3196
3499
  // problem). The draft is never thrown away by a refusal, and the
3197
3500
  // user needs to know that before deciding what to do next.
3198
3501
  error !== '' ? h('div', { className: 'im-meta' }, t('source.keepDraft')) : null,
3199
- error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
3502
+ error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
3200
3503
  h('div', { className: 'im-row-wrap' },
3201
3504
  h('button', {
3202
3505
  className: 'im-btn im-btn-primary',
@@ -3286,12 +3589,17 @@ window.__ModuleLoader__.load({
3286
3589
  className: 'im-btn im-btn-danger',
3287
3590
  disabled: busy,
3288
3591
  onClick: () => {
3289
- // confirm() is standard DOM, guarded anyway: if the embedded
3290
- // shell omits it, deletion proceeds rather than breaking.
3291
- if (typeof window.confirm === 'function'
3292
- && !window.confirm(t('entry.confirmDelete', {
3293
- title: entry.title !== '' ? entry.title : t('entry.untitled'),
3294
- }))) 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
+ }
3295
3603
  // Keep the full entry for the undo bar; the POST response
3296
3604
  // (not this closure) is what confirms the deletion happened.
3297
3605
  setUndo(null)
@@ -3411,10 +3719,12 @@ window.__ModuleLoader__.load({
3411
3719
  exports.__draftIsDirty = draftIsDirty
3412
3720
  exports.__sameSnapshot = sameSnapshot
3413
3721
  exports.__confirmDiscard = confirmDiscard
3722
+ exports.__askConfirm = askConfirm
3414
3723
  exports.__importSizeVerdict = importSizeVerdict
3415
3724
  exports.__nextBudgetText = nextBudgetText
3416
3725
  exports.__budgetMeter = budgetMeter
3417
3726
  exports.__parseTagInput = parseTagInput
3727
+ exports.__batchTagOutcome = batchTagOutcome
3418
3728
  exports.__relevanceTerms = relevanceTerms
3419
3729
  exports.__relevancePreview = relevancePreview
3420
3730
  exports.__sourceParseVerdict = sourceParseVerdict