dsh-instruction-memory 1.0.17 → 1.1.0

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
@@ -64,6 +64,34 @@ window.__ModuleLoader__.load({
64
64
  background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
65
65
  .im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
66
66
  .im-meter-fill-warn { background:var(--dsw-alias-state-warn-primary,#d97706); }
67
+ .im-entry-tags { display:flex; flex-wrap:wrap; gap:4px; }
68
+ .im-chip { display:inline-flex; align-items:center; gap:3px; padding:1px 4px 1px 9px; border-radius:999px; font-size:11px;
69
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14)); color:var(--dsw-alias-label-secondary,#6b7280);
70
+ background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03)); white-space:nowrap; }
71
+ .im-chip-x { border:none; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1;
72
+ padding:0 5px; border-radius:999px; }
73
+ .im-chip-x:hover:not(:disabled) { background:rgba(0,0,0,.08); }
74
+ .im-filter-row { display:flex; flex-direction:column; gap:7px; }
75
+ .im-filter-chips { display:flex; flex-wrap:wrap; gap:6px; }
76
+ .im-filter-chip { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
77
+ color:inherit; border-radius:999px; padding:2px 11px; font-size:11.5px; font-family:inherit; cursor:pointer; }
78
+ .im-filter-chip-on { background:var(--dsw-alias-brand-primary,#3b6cff); border-color:transparent; color:#fff; }
79
+ .im-filter-selects { display:flex; flex-wrap:wrap; gap:12px; }
80
+ .im-fselect { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
81
+ .im-select-sm { padding:3px 6px; font-size:12px; border-radius:7px; border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18));
82
+ background:var(--dsw-alias-bg-base,#fff); color:inherit; }
83
+ .im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
84
+ border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
85
+ .im-batch-check { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
86
+ .im-profiles { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:10px; padding:10px 12px;
87
+ background:var(--dsw-alias-bg-layer-1,#fff); }
88
+ .im-profiles-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
89
+ .im-profile-item { border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); border-radius:9px; padding:9px 11px;
90
+ margin-top:8px; background:var(--dsw-alias-bg-base,#fff); }
91
+ .im-profile-item-off { opacity:.55; }
92
+ .im-profile-name { font-weight:600; font-size:13.5px; }
93
+ .im-profile-sub { margin-top:3px; }
94
+ .im-section-title { font-weight:600; font-size:13px; }
67
95
  .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
68
96
  border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
69
97
  .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
@@ -157,6 +185,51 @@ window.__ModuleLoader__.load({
157
185
  'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
158
186
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
159
187
  'search.matchCount': '匹配 {shown} / {total} 条',
188
+ 'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
189
+ 'tags.placeholder': '输入标签后回车,逗号分隔多个,例如:代码规范',
190
+ 'tags.hint': '每条最多 {max} 个标签,单个不超过 {len} 字符',
191
+ 'tags.duplicate': '已有同名标签',
192
+ 'tags.full': '每条最多 {max} 个标签',
193
+ 'filter.all': '全部',
194
+ 'filter.tag': '标签',
195
+ 'filter.mode': '生效方式',
196
+ 'filter.status': '状态',
197
+ 'filter.modeAlways': '始终',
198
+ 'filter.modeAuto': '按需',
199
+ 'filter.statusEnabled': '已启用',
200
+ 'filter.statusDisabled': '已停用',
201
+ 'sort.label': '排序',
202
+ 'sort.priority': '按优先级',
203
+ 'sort.updated': '按更新时间',
204
+ 'sort.title': '按标题',
205
+ 'action.batch': '批量操作',
206
+ 'batch.exit': '退出批量',
207
+ 'batch.selectAll': '全选',
208
+ 'batch.selected': '已选 {count} 条',
209
+ 'batch.enable': '启用',
210
+ 'batch.disable': '停用',
211
+ 'batch.priority': '设优先级…',
212
+ 'batch.addTags': '加标签…',
213
+ 'batch.delete': '删除',
214
+ 'batch.confirmDelete': '确定删除选中的 {count} 条指令?删除后无法恢复。',
215
+ 'batch.tagPrompt': '输入要添加到 {count} 条指令的标签(多个用逗号分隔)',
216
+ 'batch.tagEmpty': '未输入有效的标签',
217
+ 'batch.noneSelected': '请先勾选要操作的指令',
218
+ 'batch.promptUnavailable': '当前环境不支持快捷输入,请逐条编辑标签',
219
+ 'profiles.section': '用户形象',
220
+ 'profiles.subtitle': '关于你本人的身份背景、喜好、性格与追求。勾选「使用该形象」后,AI 会在保证回答正确且扣题的前提下据此调整详略、举例与建议侧重;不会为了迎合而改变事实或偏离问题。',
221
+ 'profiles.new': '+ 新增形象',
222
+ 'profiles.name': '形象名称',
223
+ 'profiles.namePlaceholder': '例如:职业背景 / 沟通偏好 / 性格 / 长期追求',
224
+ 'profiles.content': '形象内容',
225
+ 'profiles.contentPlaceholder': '描述你本人的情况,例如:有 8 年后端开发经验,技术问题可跳过入门概念;喜欢先给结论、再给可执行步骤,少寒暄。',
226
+ 'profiles.use': '使用该形象',
227
+ 'profiles.untitled': '未命名形象',
228
+ 'profiles.confirmDelete': '确定删除用户形象「{name}」?删除后无法恢复。',
229
+ 'profiles.empty': '还没有用户形象。点击「+ 新增形象」添加关于你本人的信息;不勾选任何形象时,AI 完全按原本方式回答。',
230
+ 'profiles.count': '共 {total} 个,使用中 {active} 个',
231
+ 'profiles.chars': '已启用形象合计 {count} 字符(独立于指令字数上限,完整注入、不做裁剪)',
232
+ 'profiles.saving': '保存中…',
160
233
  'action.new': '+ 新增指令',
161
234
  'action.reload': '重新载入',
162
235
  'action.export': '导出',
@@ -173,6 +246,7 @@ window.__ModuleLoader__.load({
173
246
  'preview.empty': '(当前没有生效中的指令)',
174
247
  'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
175
248
  'list.noMatch': '没有匹配“{query}”的指令。',
249
+ 'list.filteredEmpty': '没有符合当前筛选条件的指令。',
176
250
  'tag.always': '始终',
177
251
  'tag.auto': '按需',
178
252
  'entry.enabled': '启用',
@@ -240,6 +314,51 @@ window.__ModuleLoader__.load({
240
314
  'rules.note': '“Always” applies unconditionally; “On demand” lets the model judge by relevance. When the limit is exceeded, higher-priority entries are kept and the rest are skipped.',
241
315
  'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
242
316
  'search.matchCount': '{shown} of {total} match',
317
+ 'tags.label': 'Tags (optional; used only for grouping/filtering on this page, never injected)',
318
+ 'tags.placeholder': 'Type a tag and press Enter; separate several with commas, e.g. code-style',
319
+ 'tags.hint': 'Up to {max} tags per entry, each at most {len} characters',
320
+ 'tags.duplicate': 'This tag already exists',
321
+ 'tags.full': 'Up to {max} tags per entry',
322
+ 'filter.all': 'All',
323
+ 'filter.tag': 'Tag',
324
+ 'filter.mode': 'Mode',
325
+ 'filter.status': 'Status',
326
+ 'filter.modeAlways': 'Always',
327
+ 'filter.modeAuto': 'On demand',
328
+ 'filter.statusEnabled': 'Enabled',
329
+ 'filter.statusDisabled': 'Disabled',
330
+ 'sort.label': 'Sort',
331
+ 'sort.priority': 'By priority',
332
+ 'sort.updated': 'By updated time',
333
+ 'sort.title': 'By title',
334
+ 'action.batch': 'Bulk actions',
335
+ 'batch.exit': 'Exit bulk',
336
+ 'batch.selectAll': 'Select all',
337
+ 'batch.selected': '{count} selected',
338
+ 'batch.enable': 'Enable',
339
+ 'batch.disable': 'Disable',
340
+ 'batch.priority': 'Set priority…',
341
+ 'batch.addTags': 'Add tags…',
342
+ 'batch.delete': 'Delete',
343
+ 'batch.confirmDelete': 'Delete the {count} selected entries? This cannot be undone.',
344
+ 'batch.tagPrompt': 'Enter tags to add to {count} entries (separate several with commas)',
345
+ 'batch.tagEmpty': 'No valid tag entered',
346
+ 'batch.noneSelected': 'Tick the entries to act on first',
347
+ 'batch.promptUnavailable': 'Quick entry is unavailable in this environment; edit tags one by one',
348
+ 'profiles.section': 'User persona',
349
+ '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.',
350
+ 'profiles.new': '+ New persona',
351
+ 'profiles.name': 'Persona name',
352
+ 'profiles.namePlaceholder': 'e.g. Professional background / Communication style / Personality / Goals',
353
+ 'profiles.content': 'Persona details',
354
+ 'profiles.contentPlaceholder': 'Describe yourself, e.g. 8 years of backend experience, so skip intro concepts; prefers the conclusion first, then actionable steps, no small talk.',
355
+ 'profiles.use': 'Use this persona',
356
+ 'profiles.untitled': 'Untitled persona',
357
+ 'profiles.confirmDelete': 'Delete the persona “{name}”? This cannot be undone.',
358
+ 'profiles.empty': 'No persona yet. Click “+ New persona” to add facts about yourself; with no persona ticked, the AI answers exactly as it normally would.',
359
+ 'profiles.count': '{total} in total, {active} in use',
360
+ 'profiles.chars': 'Active personas total {count} characters (separate from the instruction budget; injected in full, never trimmed)',
361
+ 'profiles.saving': 'Saving…',
243
362
  'action.new': '+ New instruction',
244
363
  'action.reload': 'Reload',
245
364
  'action.export': 'Export',
@@ -256,6 +375,7 @@ window.__ModuleLoader__.load({
256
375
  'preview.empty': '(no active instructions right now)',
257
376
  'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
258
377
  'list.noMatch': 'No instructions match “{query}”.',
378
+ 'list.filteredEmpty': 'No instructions match the current filters.',
259
379
  'tag.always': 'Always',
260
380
  'tag.auto': 'On demand',
261
381
  'entry.enabled': 'Enabled',
@@ -396,6 +516,32 @@ window.__ModuleLoader__.load({
396
516
 
397
517
  /* ---------------- data helpers ---------------- */
398
518
 
519
+ // Mirror the Host's tag limits (lib/index.js); kept local so the editor
520
+ // constrains input before the round-trip and tests can pin both to match.
521
+ const MAX_TAGS = 8
522
+ const MAX_TAG_LEN = 24
523
+
524
+ /**
525
+ * Parse free text typed into the tag box into normalised tag candidates.
526
+ * Tags are split on commas (ASCII/full-width), semicolons and newlines —
527
+ * never spaces, so a tag may contain spaces. Each piece is trimmed,
528
+ * collapsed to one line, cut to MAX_TAG_LEN, de-duplicated and capped at
529
+ * MAX_TAGS. Pinned as a pure export.
530
+ */
531
+ function parseTagInput(text) {
532
+ if (typeof text !== 'string') return []
533
+ const out = []
534
+ const seen = new Set()
535
+ for (const piece of text.split(/[,,;;\n\r]+/)) {
536
+ const tag = piece.replace(/\s+/g, ' ').trim().slice(0, MAX_TAG_LEN)
537
+ if (tag === '' || seen.has(tag)) continue
538
+ seen.add(tag)
539
+ if (out.length >= MAX_TAGS) break
540
+ out.push(tag)
541
+ }
542
+ return out
543
+ }
544
+
399
545
  function cloneEntry(entry) {
400
546
  return {
401
547
  id: entry.id,
@@ -405,12 +551,37 @@ window.__ModuleLoader__.load({
405
551
  when: entry.when,
406
552
  priority: entry.priority,
407
553
  enabled: entry.enabled,
554
+ tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
408
555
  updatedAt: entry.updatedAt,
409
556
  }
410
557
  }
411
558
 
559
+ // ---- User persona ("用户形象") drafts ----
560
+ function cloneProfile(profile) {
561
+ return {
562
+ id: profile.id,
563
+ name: profile.name,
564
+ content: profile.content,
565
+ enabled: profile.enabled === true,
566
+ updatedAt: profile.updatedAt,
567
+ }
568
+ }
569
+
570
+ function blankProfileDraft() {
571
+ return { id: '', name: '', content: '', enabled: true, updatedAt: 0 }
572
+ }
573
+
574
+ /** Whether a profile editor differs from the stored version (or a blank new one). */
575
+ function profileIsDirty(draft, profiles) {
576
+ const base = draft.id === ''
577
+ ? blankProfileDraft()
578
+ : (profiles || []).find((profile) => profile.id === draft.id)
579
+ if (base === undefined) return true
580
+ return base.name !== draft.name || base.content !== draft.content || base.enabled !== draft.enabled
581
+ }
582
+
412
583
  function blankDraft() {
413
- return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, updatedAt: 0 }
584
+ return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
414
585
  }
415
586
 
416
587
  /**
@@ -428,7 +599,11 @@ window.__ModuleLoader__.load({
428
599
  const fields = ['title', 'content', 'mode', 'when', 'priority', 'enabled']
429
600
  const base = draft.id === '' ? blankDraft() : (entries || []).find((entry) => entry.id === draft.id)
430
601
  if (base === undefined) return true
431
- return fields.some((field) => String(draft[field]) !== String(base[field]))
602
+ if (fields.some((field) => String(draft[field]) !== String(base[field]))) return true
603
+ // Tags are an optional array that may be absent on old entries; compare
604
+ // a normalised form so [] and undefined both read as "no tags".
605
+ const tagsKey = (entry) => (Array.isArray(entry && entry.tags) ? entry.tags : []).join('')
606
+ return tagsKey(draft) !== tagsKey(base)
432
607
  }
433
608
 
434
609
  /**
@@ -536,7 +711,14 @@ window.__ModuleLoader__.load({
536
711
  const budget = typeof inj.budget === 'number' && inj.budget > 0
537
712
  ? inj.budget
538
713
  : (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
539
- const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
714
+ // Measure ONLY the instruction half: on a host that publishes persona
715
+ // profiles, `chars` is the whole section (personas + instructions) while
716
+ // `entryChars` is the budgeted half. Older hosts publish neither field,
717
+ // and there `chars` is already instructions-only, so the fallback is
718
+ // correct on both.
719
+ const used = typeof inj.entryChars === 'number' && inj.entryChars >= 0
720
+ ? inj.entryChars
721
+ : (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
540
722
  const countFlagged = (list, wantTruncated) =>
541
723
  Array.isArray(list)
542
724
  ? list.filter((item) => item !== null && typeof item === 'object'
@@ -642,6 +824,13 @@ window.__ModuleLoader__.load({
642
824
  onChange: (ev) => set({ when: ev.target.value }),
643
825
  })))
644
826
  }
827
+ children.push(h('div', { key: 'tags' },
828
+ h('label', { className: 'im-label' }, t('tags.label')),
829
+ h(TagInput, {
830
+ tags: draft.tags,
831
+ disabled: props.busy,
832
+ onChange: (next) => set({ tags: next }),
833
+ })))
645
834
  children.push(h('div', { className: 'im-row-wrap', key: 'a' },
646
835
  h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
647
836
  props.busy ? t('editor.saving') : t('editor.save')),
@@ -651,6 +840,73 @@ window.__ModuleLoader__.load({
651
840
  return h('div', { className: 'im-card' }, children)
652
841
  }
653
842
 
843
+ /**
844
+ * Tag chips editor for one entry. Tags live only on this panel (they are
845
+ * never injected). Committing (Enter / comma / semicolon / blur) parses the
846
+ * typed text through parseTagInput and merges de-duplicated tags up to the
847
+ * shared cap; Backspace on an empty box removes the last chip.
848
+ */
849
+ function TagInput(props) {
850
+ const [text, setText] = React.useState('')
851
+ const tags = Array.isArray(props.tags) ? props.tags : []
852
+ const merge = (pieces) => {
853
+ const merged = []
854
+ for (const tag of [...tags, ...pieces]) {
855
+ if (!merged.includes(tag)) merged.push(tag)
856
+ }
857
+ props.onChange(merged.slice(0, MAX_TAGS))
858
+ }
859
+ const commit = () => {
860
+ const parsed = parseTagInput(text)
861
+ if (parsed.length > 0) merge(parsed)
862
+ setText('')
863
+ }
864
+ const remove = (tag) => {
865
+ props.onChange(tags.filter((candidate) => candidate !== tag))
866
+ }
867
+ return h('div', null,
868
+ tags.length > 0
869
+ ? h('div', { className: 'im-entry-tags' },
870
+ tags.map((tag) => h('span', { className: 'im-chip', key: tag },
871
+ tag,
872
+ h('button', {
873
+ type: 'button', className: 'im-chip-x', disabled: props.disabled,
874
+ onClick: () => remove(tag),
875
+ }, '×'))))
876
+ : null,
877
+ h('input', {
878
+ className: 'im-input',
879
+ value: text,
880
+ disabled: props.disabled,
881
+ maxLength: MAX_TAG_LEN,
882
+ placeholder: t('tags.placeholder'),
883
+ onChange: (ev) => {
884
+ const v = ev.target.value
885
+ if (/[,,;;\n\r]/.test(v)) {
886
+ // A separator commits the pending text as chips immediately.
887
+ // v is the input's full new value (it already contains what was
888
+ // typed before the separator) — do NOT prepend `text`, that
889
+ // duplicated it ("b" + "b," -> "bb").
890
+ const parsed = parseTagInput(v)
891
+ if (parsed.length > 0) merge(parsed)
892
+ setText('')
893
+ } else {
894
+ setText(v.slice(0, MAX_TAG_LEN))
895
+ }
896
+ },
897
+ onKeyDown: (ev) => {
898
+ if (ev.key === 'Enter') {
899
+ ev.preventDefault()
900
+ commit()
901
+ } else if (ev.key === 'Backspace' && text === '' && tags.length > 0) {
902
+ remove(tags[tags.length - 1])
903
+ }
904
+ },
905
+ onBlur: commit,
906
+ }),
907
+ h('div', { className: 'im-meta' }, t('tags.hint', { max: MAX_TAGS, len: MAX_TAG_LEN })))
908
+ }
909
+
654
910
  /**
655
911
  * Entry body with an explicit 展开/收起 toggle. The collapsed cap lives in
656
912
  * CSS (max-height + overflow:hidden), so whether overflow actually happens
@@ -672,12 +928,60 @@ window.__ModuleLoader__.load({
672
928
  /** How long the undo bar keeps its offer alive after a confirmed delete. */
673
929
  const UNDO_TTL_MS = 10000
674
930
 
931
+ /**
932
+ * Editor for one "用户形象" (facts about the user). Deliberately minimal:
933
+ * a single-line name and a multi-line description. Save is disabled while
934
+ * both are blank (the Host would reject that anyway). The enable toggle
935
+ * lives on the card, not here: the editor edits content, the card decides
936
+ * whether the persona is used.
937
+ */
938
+ function ProfileEditor(props) {
939
+ const limits = props.limits || {}
940
+ const maxName = typeof limits.maxProfileName === 'number' ? limits.maxProfileName : 40
941
+ const maxContent = typeof limits.maxProfileContent === 'number' ? limits.maxProfileContent : 4000
942
+ const set = (patch) => props.onChange(Object.assign({}, props.draft, patch))
943
+ const canSave = (props.draft.name.trim() !== '' || props.draft.content.trim() !== '')
944
+ return h('div', { className: 'im-editor im-profiles' },
945
+ h('div', null,
946
+ h('label', { className: 'im-label' }, t('profiles.name')),
947
+ h('input', {
948
+ className: 'im-input',
949
+ value: props.draft.name,
950
+ maxLength: maxName,
951
+ placeholder: t('profiles.namePlaceholder'),
952
+ disabled: props.busy,
953
+ onChange: (ev) => set({ name: ev.target.value }),
954
+ })),
955
+ h('div', null,
956
+ h('label', { className: 'im-label' }, t('profiles.content')),
957
+ h('textarea', {
958
+ className: 'im-textarea',
959
+ rows: 6,
960
+ value: props.draft.content,
961
+ maxLength: maxContent,
962
+ placeholder: t('profiles.contentPlaceholder'),
963
+ disabled: props.busy,
964
+ onChange: (ev) => set({ content: ev.target.value }),
965
+ })),
966
+ h('div', { className: 'im-row-wrap' },
967
+ h('button', {
968
+ className: 'im-btn im-btn-primary',
969
+ disabled: props.busy || !canSave,
970
+ onClick: props.onSave,
971
+ }, props.busy ? t('profiles.saving') : t('editor.save')),
972
+ h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, t('editor.cancel'))))
973
+ }
974
+
675
975
  function InstructionPanel() {
676
976
  const [view, setView] = React.useState(null)
677
977
  const [error, setError] = React.useState('')
678
978
  const [notice, setNotice] = React.useState('')
679
979
  const [busy, setBusy] = React.useState(false)
680
980
  const [draft, setDraft] = React.useState(null)
981
+ // The persona editor draft (null = closed). Independent of the entry
982
+ // draft: editing an instruction never closes an open persona and vice
983
+ // versa is handled by closing one when opening the other (see handlers).
984
+ const [profileDraft, setProfileDraft] = React.useState(null)
681
985
  const [budgetText, setBudgetText] = React.useState('')
682
986
  // Latest live value of the draft, because the SSE onmessage closure below
683
987
  // is created once and must not read a stale first-render budgetText.
@@ -686,6 +990,14 @@ window.__ModuleLoader__.load({
686
990
  const [showPreview, setShowPreview] = React.useState(false)
687
991
  const [query, setQuery] = React.useState('')
688
992
  const [undo, setUndo] = React.useState(null)
993
+ // F2: tag/mode/status filters ('' = all) and the list sort key.
994
+ const [tagFilter, setTagFilter] = React.useState('')
995
+ const [modeFilter, setModeFilter] = React.useState('')
996
+ const [statusFilter, setStatusFilter] = React.useState('')
997
+ const [sortKey, setSortKey] = React.useState('priority')
998
+ // F3: bulk-select mode and the chosen entry ids in it.
999
+ const [batchMode, setBatchMode] = React.useState(false)
1000
+ const [selected, setSelected] = React.useState([])
689
1001
  const importInputRef = React.useRef(null)
690
1002
  // Whether the budget input is being edited right now. A live SSE push
691
1003
  // from another window must not replace the number under the cursor;
@@ -770,6 +1082,87 @@ window.__ModuleLoader__.load({
770
1082
  }
771
1083
  }
772
1084
 
1085
+ // ---- F3 bulk actions: one request, one host-side transaction ----
1086
+
1087
+ /** Selected ids that still exist in the latest snapshot. */
1088
+ const liveSelected = () => selected.filter((id) => data.entries.some((entry) => entry.id === id))
1089
+
1090
+ function toggleSelected(id) {
1091
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
1092
+ }
1093
+
1094
+ function exitBatch() {
1095
+ setBatchMode(false)
1096
+ setSelected([])
1097
+ }
1098
+
1099
+ async function doBatch(op, value) {
1100
+ const ids = liveSelected()
1101
+ if (ids.length === 0) {
1102
+ setError(t('batch.noneSelected'))
1103
+ return
1104
+ }
1105
+ if (op === 'delete'
1106
+ && typeof window.confirm === 'function'
1107
+ && !window.confirm(t('batch.confirmDelete', { count: ids.length }))) {
1108
+ return
1109
+ }
1110
+ const result = await send('batch', { op, ids, value, baseRev: revOf() })
1111
+ if (result !== null && result.ok === true && op === 'delete') {
1112
+ // Drop deleted ids from the selection; other ops keep it so several
1113
+ // bulk actions can be chained (e.g. set priority, then disable).
1114
+ setSelected((prev) => prev.filter((id) => !ids.includes(id)))
1115
+ }
1116
+ }
1117
+
1118
+ function batchAddTags() {
1119
+ if (typeof window.prompt !== 'function') {
1120
+ setError(t('batch.promptUnavailable'))
1121
+ return
1122
+ }
1123
+ const answer = window.prompt(t('batch.tagPrompt', { count: liveSelected().length }), '')
1124
+ if (answer === null) return
1125
+ const tags = parseTagInput(answer)
1126
+ if (tags.length === 0) {
1127
+ setError(t('batch.tagEmpty'))
1128
+ return
1129
+ }
1130
+ void doBatch('add-tags', tags)
1131
+ }
1132
+
1133
+ // ---- User persona ("用户形象") handlers ----
1134
+
1135
+ async function saveProfileDraft() {
1136
+ if (profileDraft === null) return
1137
+ if (profileDraft.name.trim() === '' && profileDraft.content.trim() === '') return
1138
+ const result = await send('save-profile', { profile: cloneProfile(profileDraft), baseRev: revOf() })
1139
+ if (result !== null && result.ok === true) setProfileDraft(null)
1140
+ }
1141
+
1142
+ function toggleProfile(profile) {
1143
+ if (busy) return
1144
+ const next = cloneProfile(profile)
1145
+ next.enabled = !profile.enabled
1146
+ void send('save-profile', { profile: next, baseRev: revOf() })
1147
+ }
1148
+
1149
+ function editProfile(profile) {
1150
+ if (busy) return
1151
+ // The two editors are mutually exclusive so the panel stays legible.
1152
+ setDraft(null)
1153
+ setProfileDraft(cloneProfile(profile))
1154
+ setError('')
1155
+ }
1156
+
1157
+ function removeProfile(profile) {
1158
+ if (busy) return
1159
+ if (typeof window.confirm === 'function'
1160
+ && !window.confirm(t('profiles.confirmDelete', {
1161
+ name: profile.name !== '' ? profile.name : t('profiles.untitled'),
1162
+ }))) return
1163
+ void send('delete-profile', { id: profile.id, baseRev: revOf() })
1164
+ }
1165
+
773
1166
  async function doExport() {
774
1167
  try {
775
1168
  const result = await callHost('export-data', null)
@@ -889,8 +1282,47 @@ window.__ModuleLoader__.load({
889
1282
  const storage = view.storage || {}
890
1283
  const limits = view.limits || { minBudget: 800, maxBudget: 40000, maxEntries: 200 }
891
1284
  const injection = view.injection || { registered: false, chars: 0, error: null }
892
- const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
893
- const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
1285
+ // List order is display-only and never changes what the Host injects
1286
+ // (the injection order is decided host-side). The default mirrors the
1287
+ // previous fixed order; updated-time and title are the other sorts.
1288
+ const sortEntries = (list) => {
1289
+ const copy = list.slice()
1290
+ if (sortKey === 'updated') {
1291
+ copy.sort((a, b) => (b.updatedAt - a.updatedAt) || (b.priority - a.priority))
1292
+ } else if (sortKey === 'title') {
1293
+ copy.sort((a, b) =>
1294
+ a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
1295
+ || (b.priority - a.priority)
1296
+ || (b.updatedAt - a.updatedAt))
1297
+ } else {
1298
+ copy.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
1299
+ }
1300
+ return copy
1301
+ }
1302
+ const entries = sortEntries(data.entries)
1303
+ const activeCount = data.entries.filter((e) => e.enabled && e.content !== '').length
1304
+ // Persona profiles, in stored order (stable injection order). Old hosts
1305
+ // simply have no array.
1306
+ const profiles = Array.isArray(data.profiles) ? data.profiles : []
1307
+ const activeProfiles = profiles.filter((p) => p.enabled === true && p.content !== '').length
1308
+ // Prefer the Host's rendered size (includes the shared header); fall
1309
+ // back to a local sum for hosts that do not publish the field yet.
1310
+ const profileChars = injection && typeof injection === 'object' && typeof injection.profileChars === 'number'
1311
+ ? injection.profileChars
1312
+ : profiles.reduce((n, p) => n + (p.enabled === true && p.content ? p.name.length + p.content.length + 5 : 0), 0)
1313
+ // Every tag actually in use, most frequent first (then name), for the
1314
+ // tag filter row.
1315
+ const allTags = (() => {
1316
+ const counts = new Map()
1317
+ for (const entry of data.entries) {
1318
+ for (const tag of Array.isArray(entry.tags) ? entry.tags : []) {
1319
+ if (typeof tag === 'string' && tag !== '') counts.set(tag, (counts.get(tag) || 0) + 1)
1320
+ }
1321
+ }
1322
+ return [...counts.entries()]
1323
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }))
1324
+ .map(([tag]) => tag)
1325
+ })()
894
1326
  // Budget usage + per-entry marks straight from the Host's injection plan.
895
1327
  // Older hosts simply publish none of these fields: the meter falls back
896
1328
  // to data.budgetChars and the maps stay empty.
@@ -909,12 +1341,23 @@ window.__ModuleLoader__.load({
909
1341
  }
910
1342
  }
911
1343
  const keyword = query.trim().toLowerCase()
912
- const visible = keyword === ''
913
- ? entries
914
- : entries.filter((entry) =>
915
- entry.title.toLowerCase().indexOf(keyword) !== -1
1344
+ const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
1345
+ const matchesFilters = (entry) => {
1346
+ if (tagFilter !== '' && !(Array.isArray(entry.tags) && entry.tags.includes(tagFilter))) return false
1347
+ if (modeFilter !== '' && entry.mode !== modeFilter) return false
1348
+ if (statusFilter === 'enabled' && entry.enabled !== true) return false
1349
+ if (statusFilter === 'disabled' && entry.enabled !== false) return false
1350
+ return true
1351
+ }
1352
+ const visible = entries.filter((entry) => {
1353
+ if (!matchesFilters(entry)) return false
1354
+ if (keyword === '') return true
1355
+ const inTags = (Array.isArray(entry.tags) ? entry.tags.join(' ') : '').toLowerCase()
1356
+ return entry.title.toLowerCase().indexOf(keyword) !== -1
916
1357
  || entry.content.toLowerCase().indexOf(keyword) !== -1
917
- || entry.when.toLowerCase().indexOf(keyword) !== -1)
1358
+ || entry.when.toLowerCase().indexOf(keyword) !== -1
1359
+ || inTags.indexOf(keyword) !== -1
1360
+ })
918
1361
 
919
1362
  const dotClass = injection.error
920
1363
  ? 'im-dot im-dot-err'
@@ -986,6 +1429,156 @@ window.__ModuleLoader__.load({
986
1429
  ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
987
1430
  : null)
988
1431
  : null,
1432
+ // ---- User persona ("用户形象"): facts about the USER, not a role ----
1433
+ h('div', { key: 'profiles', className: 'im-profiles' },
1434
+ h('div', { className: 'im-profiles-head' },
1435
+ h('span', { className: 'im-section-title' }, t('profiles.section')),
1436
+ h('button', {
1437
+ className: 'im-btn im-btn-primary',
1438
+ disabled: busy,
1439
+ onClick: () => { setProfileDraft(blankProfileDraft()); setDraft(null); setError('') },
1440
+ }, t('profiles.new')),
1441
+ h('span', { className: 'im-spacer' }),
1442
+ h('span', { className: 'im-meta' }, t('profiles.count', { total: profiles.length, active: activeProfiles }))),
1443
+ h('div', { className: 'im-meta im-profile-sub' }, t('profiles.subtitle')),
1444
+ profileDraft !== null
1445
+ ? h(ProfileEditor, {
1446
+ draft: profileDraft,
1447
+ busy: busy,
1448
+ limits: view !== null && view.limits ? view.limits : null,
1449
+ onChange: setProfileDraft,
1450
+ onSave: saveProfileDraft,
1451
+ onCancel: () => setProfileDraft(null),
1452
+ })
1453
+ : null,
1454
+ profiles.length === 0 && profileDraft === null
1455
+ ? h('div', { className: 'im-empty' }, t('profiles.empty'))
1456
+ : profiles.map((profile) => h('div', {
1457
+ className: 'im-profile-item' + (profile.enabled === true ? '' : ' im-profile-item-off'),
1458
+ key: profile.id,
1459
+ },
1460
+ h('div', { className: 'im-item-head' },
1461
+ h('span', { className: 'im-profile-name' },
1462
+ profile.name !== '' ? profile.name : t('profiles.untitled')),
1463
+ h('span', { className: 'im-spacer' }),
1464
+ h('label', { className: 'im-switch' },
1465
+ h('input', {
1466
+ type: 'checkbox',
1467
+ checked: profile.enabled === true,
1468
+ disabled: busy,
1469
+ onChange: () => toggleProfile(profile),
1470
+ }),
1471
+ t('profiles.use')),
1472
+ h('button', {
1473
+ className: 'im-btn',
1474
+ disabled: busy,
1475
+ onClick: () => editProfile(profile),
1476
+ }, t('entry.edit')),
1477
+ h('button', {
1478
+ className: 'im-btn im-btn-danger',
1479
+ disabled: busy,
1480
+ onClick: () => removeProfile(profile),
1481
+ }, t('entry.delete'))),
1482
+ h(EntryBody, { text: profile.content, key: 'body' }),
1483
+ profile.updatedAt > 0
1484
+ ? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(profile.updatedAt).toLocaleString() }))
1485
+ : null)),
1486
+ activeProfiles > 0
1487
+ ? h('div', { className: 'im-meta im-meter-line' }, t('profiles.chars', { count: profileChars }))
1488
+ : null),
1489
+ // F3 bulk action bar, only while in multi-select mode. "Select all"
1490
+ // covers the currently visible (filtered/searched) entries.
1491
+ (batchMode && entries.length > 0)
1492
+ ? h('div', { key: 'batchbar', className: 'im-batch-bar' },
1493
+ h('label', { className: 'im-switch' },
1494
+ h('input', {
1495
+ type: 'checkbox',
1496
+ className: 'im-batch-check',
1497
+ checked: visible.length > 0 && visible.every((entry) => selected.includes(entry.id)),
1498
+ disabled: busy,
1499
+ onChange: (ev) => {
1500
+ const visibleIds = visible.map((entry) => entry.id)
1501
+ if (ev.target.checked) {
1502
+ setSelected([...new Set([...selected, ...visibleIds])])
1503
+ } else {
1504
+ setSelected(selected.filter((id) => !visibleIds.includes(id)))
1505
+ }
1506
+ },
1507
+ }),
1508
+ t('batch.selectAll')),
1509
+ h('span', { className: 'im-meta' }, t('batch.selected', { count: liveSelected().length })),
1510
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', true) }, t('batch.enable')),
1511
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => doBatch('set-enabled', false) }, t('batch.disable')),
1512
+ h('select', {
1513
+ className: 'im-select im-select-sm',
1514
+ defaultValue: '',
1515
+ disabled: busy,
1516
+ onChange: (ev) => {
1517
+ const v = Number(ev.target.value)
1518
+ if (v === 0 || v === 1 || v === 2) void doBatch('set-priority', v)
1519
+ ev.target.value = ''
1520
+ },
1521
+ },
1522
+ h('option', { value: '' }, t('batch.priority')),
1523
+ h('option', { value: '2' }, t('priority.high')),
1524
+ h('option', { value: '1' }, t('priority.normal')),
1525
+ h('option', { value: '0' }, t('priority.low'))),
1526
+ h('button', { className: 'im-btn', disabled: busy, onClick: batchAddTags }, t('batch.addTags')),
1527
+ h('span', { className: 'im-spacer' }),
1528
+ h('button', {
1529
+ className: 'im-btn im-btn-danger',
1530
+ disabled: busy,
1531
+ onClick: () => doBatch('delete', undefined),
1532
+ }, t('batch.delete')),
1533
+ h('button', { className: 'im-btn', disabled: busy, onClick: exitBatch }, t('batch.exit')))
1534
+ : null,
1535
+ // F2 filters: tag chips (only when tags exist) plus mode/status/sort.
1536
+ entries.length > 0
1537
+ ? h('div', { key: 'filters', className: 'im-filter-row' },
1538
+ allTags.length > 0
1539
+ ? h('div', { className: 'im-filter-chips' },
1540
+ h('button', {
1541
+ type: 'button',
1542
+ className: 'im-filter-chip' + (tagFilter === '' ? ' im-filter-chip-on' : ''),
1543
+ onClick: () => setTagFilter(''),
1544
+ }, t('filter.all')),
1545
+ allTags.map((tag) => h('button', {
1546
+ type: 'button',
1547
+ key: tag,
1548
+ className: 'im-filter-chip' + (tagFilter === tag ? ' im-filter-chip-on' : ''),
1549
+ title: t('filter.tag'),
1550
+ onClick: () => setTagFilter(tagFilter === tag ? '' : tag),
1551
+ }, tag)))
1552
+ : null,
1553
+ h('div', { className: 'im-filter-selects' },
1554
+ h('label', { className: 'im-fselect' }, t('filter.mode'),
1555
+ h('select', {
1556
+ className: 'im-select im-select-sm',
1557
+ value: modeFilter,
1558
+ onChange: (ev) => setModeFilter(ev.target.value),
1559
+ },
1560
+ h('option', { value: '' }, t('filter.all')),
1561
+ h('option', { value: 'always' }, t('filter.modeAlways')),
1562
+ h('option', { value: 'auto' }, t('filter.modeAuto')))),
1563
+ h('label', { className: 'im-fselect' }, t('filter.status'),
1564
+ h('select', {
1565
+ className: 'im-select im-select-sm',
1566
+ value: statusFilter,
1567
+ onChange: (ev) => setStatusFilter(ev.target.value),
1568
+ },
1569
+ h('option', { value: '' }, t('filter.all')),
1570
+ h('option', { value: 'enabled' }, t('filter.statusEnabled')),
1571
+ h('option', { value: 'disabled' }, t('filter.statusDisabled')))),
1572
+ h('label', { className: 'im-fselect' }, t('sort.label'),
1573
+ h('select', {
1574
+ className: 'im-select im-select-sm',
1575
+ value: sortKey,
1576
+ onChange: (ev) => setSortKey(ev.target.value),
1577
+ },
1578
+ h('option', { value: 'priority' }, t('sort.priority')),
1579
+ h('option', { value: 'updated' }, t('sort.updated')),
1580
+ h('option', { value: 'title' }, t('sort.title'))))))
1581
+ : null,
989
1582
  entries.length > 0 ? h('div', { key: 'search' },
990
1583
  h('input', {
991
1584
  className: 'im-input',
@@ -993,12 +1586,14 @@ window.__ModuleLoader__.load({
993
1586
  placeholder: t('search.placeholder', { count: entries.length }),
994
1587
  onChange: (ev) => setQuery(ev.target.value),
995
1588
  }),
996
- query.trim() !== '' ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length })) : null) : null,
1589
+ (query.trim() !== '' || filtersActive)
1590
+ ? h('div', { className: 'im-meta im-search-meta' }, t('search.matchCount', { shown: visible.length, total: entries.length }))
1591
+ : null) : null,
997
1592
  h('div', { className: 'im-row-wrap' },
998
1593
  h('button', {
999
1594
  className: 'im-btn im-btn-primary',
1000
1595
  disabled: busy,
1001
- onClick: () => { setDraft(blankDraft()); setError('') },
1596
+ onClick: () => { setDraft(blankDraft()); setProfileDraft(null); setError('') },
1002
1597
  }, t('action.new')),
1003
1598
  h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, t('action.reload')),
1004
1599
  h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, t('action.export')),
@@ -1015,6 +1610,11 @@ window.__ModuleLoader__.load({
1015
1610
  disabled: busy,
1016
1611
  onClick: () => setShowPreview(!showPreview),
1017
1612
  }, showPreview ? t('action.hidePreview') : t('action.showPreview')),
1613
+ h('button', {
1614
+ className: 'im-btn',
1615
+ disabled: busy || entries.length === 0,
1616
+ onClick: () => { setBatchMode(!batchMode); setSelected([]) },
1617
+ }, batchMode ? t('batch.exit') : t('action.batch')),
1018
1618
  h('span', { className: 'im-spacer' }),
1019
1619
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
1020
1620
  h('div', { className: 'im-divider' }),
@@ -1087,46 +1687,65 @@ window.__ModuleLoader__.load({
1087
1687
  key: entry.id,
1088
1688
  },
1089
1689
  h('div', { className: 'im-item-head' },
1690
+ batchMode
1691
+ ? h('input', {
1692
+ type: 'checkbox',
1693
+ className: 'im-batch-check',
1694
+ checked: selected.includes(entry.id),
1695
+ disabled: busy,
1696
+ onChange: () => toggleSelected(entry.id),
1697
+ })
1698
+ : null,
1090
1699
  h('span', { className: 'im-title' }, entry.title),
1091
1700
  tags,
1092
1701
  h('span', { className: 'im-spacer' }),
1093
- h('label', { className: 'im-switch' },
1094
- h('input', {
1095
- type: 'checkbox',
1096
- checked: entry.enabled === true,
1702
+ batchMode
1703
+ ? null
1704
+ : h('label', { className: 'im-switch' },
1705
+ h('input', {
1706
+ type: 'checkbox',
1707
+ checked: entry.enabled === true,
1708
+ disabled: busy,
1709
+ onChange: () => {
1710
+ const next = cloneEntry(entry)
1711
+ next.enabled = !entry.enabled
1712
+ send('save-entry', { entry: next, baseRev: revOf() })
1713
+ },
1714
+ }),
1715
+ t('entry.enabled')),
1716
+ batchMode
1717
+ ? null
1718
+ : h('button', {
1719
+ className: 'im-btn',
1097
1720
  disabled: busy,
1098
- onChange: () => {
1099
- const next = cloneEntry(entry)
1100
- next.enabled = !entry.enabled
1101
- send('save-entry', { entry: next, baseRev: revOf() })
1721
+ onClick: () => { setDraft(cloneEntry(entry)); setProfileDraft(null); setError('') },
1722
+ }, t('entry.edit')),
1723
+ batchMode
1724
+ ? null
1725
+ : h('button', {
1726
+ className: 'im-btn im-btn-danger',
1727
+ disabled: busy,
1728
+ onClick: () => {
1729
+ // confirm() is standard DOM, guarded anyway: if the embedded
1730
+ // shell omits it, deletion proceeds rather than breaking.
1731
+ if (typeof window.confirm === 'function'
1732
+ && !window.confirm(t('entry.confirmDelete', {
1733
+ title: entry.title !== '' ? entry.title : t('entry.untitled'),
1734
+ }))) return
1735
+ // Keep the full entry for the undo bar; the POST response
1736
+ // (not this closure) is what confirms the deletion happened.
1737
+ setUndo(null)
1738
+ send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
1739
+ if (result === null || result.ok !== true) return
1740
+ setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
1741
+ })
1102
1742
  },
1103
- }),
1104
- t('entry.enabled')),
1105
- h('button', {
1106
- className: 'im-btn',
1107
- disabled: busy,
1108
- onClick: () => { setDraft(cloneEntry(entry)); setError('') },
1109
- }, t('entry.edit')),
1110
- h('button', {
1111
- className: 'im-btn im-btn-danger',
1112
- disabled: busy,
1113
- onClick: () => {
1114
- // confirm() is standard DOM, guarded anyway: if the embedded
1115
- // shell omits it, deletion proceeds rather than breaking.
1116
- if (typeof window.confirm === 'function'
1117
- && !window.confirm(t('entry.confirmDelete', {
1118
- title: entry.title !== '' ? entry.title : t('entry.untitled'),
1119
- }))) return
1120
- // Keep the full entry for the undo bar; the POST response
1121
- // (not this closure) is what confirms the deletion happened.
1122
- setUndo(null)
1123
- send('delete-entry', { id: entry.id, baseRev: revOf() }).then((result) => {
1124
- if (result === null || result.ok !== true) return
1125
- setUndo({ entry, expires: Date.now() + UNDO_TTL_MS })
1126
- })
1127
- },
1128
- }, t('entry.delete'))),
1743
+ }, t('entry.delete'))),
1129
1744
  h(EntryBody, { text: entry.content, key: 'body' }),
1745
+ Array.isArray(entry.tags) && entry.tags.length > 0
1746
+ ? h('div', { className: 'im-entry-tags' },
1747
+ entry.tags.map((tag) => h('span', { className: 'im-chip', key: tag }, tag)))
1748
+ : null,
1130
1749
  entry.mode === 'auto' && entry.when !== ''
1131
1750
  ? h('div', { className: 'im-meta' }, t('entry.when', { when: entry.when }))
1132
1751
  : null,
@@ -1142,7 +1761,10 @@ window.__ModuleLoader__.load({
1142
1761
  ? h('div', { className: 'im-empty', key: 'empty' },
1143
1762
  t('list.empty'))
1144
1763
  : visible.length === 0
1145
- ? h('div', { className: 'im-empty', key: 'no-match' }, t('list.noMatch', { query: query.trim() }))
1764
+ ? h('div', { className: 'im-empty', key: 'no-match' },
1765
+ keyword === ''
1766
+ ? t('list.filteredEmpty')
1767
+ : t('list.noMatch', { query: query.trim() }))
1146
1768
  : h('div', { className: 'im-list', key: 'list' }, items)
1147
1769
 
1148
1770
  return h('div', { className: 'im-root' }, header, list)
@@ -1232,6 +1854,7 @@ window.__ModuleLoader__.load({
1232
1854
  exports.__importSizeVerdict = importSizeVerdict
1233
1855
  exports.__nextBudgetText = nextBudgetText
1234
1856
  exports.__budgetMeter = budgetMeter
1857
+ exports.__parseTagInput = parseTagInput
1235
1858
  exports.__strings = STRINGS
1236
1859
  exports.__ns = NS
1237
1860
  exports.__dictionaryT = dictionaryT