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/CHANGELOG.md +60 -0
- package/README.md +35 -3
- package/lib/client.js +670 -47
- package/lib/index.js +465 -26
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
893
|
-
|
|
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
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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() !== ''
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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' },
|
|
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
|