dsh-instruction-memory 1.0.18 → 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 CHANGED
@@ -7,6 +7,38 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.1.0] — 2026-10-13
11
+
12
+ ### 新增
13
+
14
+ - **用户形象(关于用户本人的画像)**:设置页新增「用户形象」分区,可维护若干关于**用户本人**的信息
15
+ (身份背景、喜好、性格、长期追求等),每张卡片可单独勾选「使用该形象」——支持不使用、使用一个或
16
+ 同时使用多个(多个作为同一画像的不同侧面综合)。这与"要求 AI 怎么做"的指令条目是两类数据:
17
+ - Host 新增可选 `profiles: [{ id, name, content, enabled, updatedAt }]`(与 `entries` 平级,存储格式
18
+ 仍为 v1,旧记忆无该字段即按空数组加载,无需迁移);名称单行 ≤40 字符、内容 ≤4000 字符、最多 50 个,
19
+ 名称与内容同时为空或超限时载入会剔除并明确披露,名称缺失时取内容前 20 字符。
20
+ - 注入时形象段以「## 关于用户(用户形象)」抬头输出在指令块**之前**,抬头自带优先级护栏:仅供 AI
21
+ 在保证回答正确、完整、客观且扣题的前提下调整详略/举例/建议侧重,不得为迎合而改变事实、省略风险
22
+ 提示或偏离问题,无关时忽略;形象不是让 AI 扮演的角色。
23
+ - 形象段**不参与指令字数预算裁剪**,始终完整注入;快照的 `injection` 新增 `profiles` / `profileChars`
24
+ / `entryChars`(指令用量条只计量指令部分,不被形象体积顶高)。
25
+ - 新增 `save-profile` / `delete-profile` 路由(复用 rev 乐观锁、原子写盘、未来版本拒绝、写盘失败
26
+ 回滚与 SSE 多窗口同步);导入/导出支持 `profiles`,导入仍为只增合并(同 id 或同名+同内容去重),
27
+ 指令与形象在同一事务内落盘;空形象拒绝保存;请求体上限推导纳入形象容量。
28
+ - 设置页新增形象卡片列表(勾选开关、展开/收起、编辑、删除确认)、独立编辑器与已启用形象合计字符
29
+ 提示;旧宿主不发布形象字段时客户端自动按无形象处理,两侧均向前/向后兼容。
30
+
31
+ ### 测试
32
+
33
+ - `smoke-test.mjs` 新增形象清洗与注入语义断言(0 个时与旧块逐字节一致、1/多个抬头与顺序、停用/空内容
34
+ 不注入、仅有形象时仍注入、总开关同时关闭两段、形象不受预算裁剪、确定性)。
35
+ - `contract-test.mjs` 新增独立形象验证块:经真实路由断言保存/更新/删除(rev 恰好 +1、no-op 不写盘)、
36
+ 空形象拒绝、stale rev 冲突、快照与 preview 字段、勾选/取消勾选的注入变化、50 个上限、混合导入一次
37
+ 事务与去重、仅含形象的导入文件有效。
38
+ - `verify.mjs` 新增形象双语文案键断言,以及预算用量条在形象宿主上只取 `entryChars` 的接缝断言。
39
+ - `docs/demo.html` 演示数据加入 3 个形象(含停用示例),演示宿主支持 `save-profile`/`delete-profile`
40
+ 并实时计算形象注入段。
41
+
10
42
  ## [1.0.18] — 2026-10-13
11
43
 
12
44
  ### 新增
package/README.md CHANGED
@@ -67,8 +67,16 @@ dsh plugin --profile web remove dsh-instruction-memory
67
67
  多选与上方的筛选/搜索叠加(「全选」只作用于当前可见条目)。整批修改在宿主侧是**单次写入**
68
68
  (只产生一次落盘、一次修订号递增与一次同步推送),全部条目已是目标状态时不写盘;批量删除需确认。
69
69
  批量的启停/优先级/标签调整不重盖更新时间,因此不会改变同优先级下的注入先后。
70
+ - **用户形象**:除了"要求 AI 怎么做"的指令,你还可以记录**关于你本人**的信息(身份背景、喜好、性格、
71
+ 长期追求等),每张形象卡片可单独勾选「使用该形象」——可以一个都不用、只用一个、或同时勾选多个
72
+ (多个会作为同一画像的不同侧面综合)。启用后,形象段会以「## 关于用户(用户形象)」为抬头注入在
73
+ 指令块**之前**,抬头明确把它限定为"仅供调整回答侧重":AI 应在**保证回答正确、完整、客观且扣题**的
74
+ 前提下据此调整解释深浅、详略、举例与建议方向,**不得为了迎合而改变事实、省略风险提示或偏离问题本身**,
75
+ 无关时直接忽略。形象不是让 AI 扮演的角色。形象段独立于指令字数上限,始终完整注入、不做预算裁剪;
76
+ 页面会显示已启用形象的合计字符数,便于你权衡提示词体积。总开关关闭时形象与指令一起停用。
70
77
 
71
- 条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
78
+ 总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
79
+ 总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
72
80
 
73
81
  ## 存储
74
82
 
@@ -448,6 +456,15 @@ Package → Settings → Trusted publishing → Add trusted publisher
448
456
  "tags": ["沟通"],
449
457
  "updatedAt": 1700000000000
450
458
  }
459
+ ],
460
+ "profiles": [
461
+ {
462
+ "id": "pf-xxxx",
463
+ "name": "职业背景",
464
+ "content": "有 8 年后端开发经验,技术问题可跳过入门概念。",
465
+ "enabled": true,
466
+ "updatedAt": 1700000000000
467
+ }
451
468
  ]
452
469
  }
453
470
  ```
@@ -456,3 +473,7 @@ Package → Settings → Trusted publishing → Add trusted publisher
456
473
  由插件在每次成功写入时递增,**无需手填**(缺失视为 0)。`tags` 为可选的短标签字符串数组
457
474
  (最多 8 个、单个不超过 24 字符,仅用于设置页筛选,不参与注入);缺失或非数组一律按无标签处理,
458
475
  非法项会被剔除、超长会被截断、重复项会去重,格式版本号仍为 `1`。
476
+
477
+ `profiles` 为可选的**用户形象**数组(缺失按空数组处理,旧记忆无需迁移):`name` 单行不超过 40 字符,
478
+ `content` 不超过 4000 字符,最多 50 个;`enabled` 控制是否使用该形象。名称与内容同时为空的形象会在
479
+ 载入时剔除并明确披露;名称为空时自动取内容前 20 字符。形象保存在同一文件、同一格式版本 `1` 中。
package/lib/client.js CHANGED
@@ -83,6 +83,15 @@ window.__ModuleLoader__.load({
83
83
  .im-batch-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 12px; border-radius:10px;
84
84
  border:1px solid var(--dsw-alias-state-warn-primary,#d97706); background:rgba(217,119,6,.08); }
85
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; }
86
95
  .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
87
96
  border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
88
97
  .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
@@ -207,6 +216,20 @@ window.__ModuleLoader__.load({
207
216
  'batch.tagEmpty': '未输入有效的标签',
208
217
  'batch.noneSelected': '请先勾选要操作的指令',
209
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': '保存中…',
210
233
  'action.new': '+ 新增指令',
211
234
  'action.reload': '重新载入',
212
235
  'action.export': '导出',
@@ -322,6 +345,20 @@ window.__ModuleLoader__.load({
322
345
  'batch.tagEmpty': 'No valid tag entered',
323
346
  'batch.noneSelected': 'Tick the entries to act on first',
324
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…',
325
362
  'action.new': '+ New instruction',
326
363
  'action.reload': 'Reload',
327
364
  'action.export': 'Export',
@@ -519,6 +556,30 @@ window.__ModuleLoader__.load({
519
556
  }
520
557
  }
521
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
+
522
583
  function blankDraft() {
523
584
  return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
524
585
  }
@@ -650,7 +711,14 @@ window.__ModuleLoader__.load({
650
711
  const budget = typeof inj.budget === 'number' && inj.budget > 0
651
712
  ? inj.budget
652
713
  : (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
653
- 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)
654
722
  const countFlagged = (list, wantTruncated) =>
655
723
  Array.isArray(list)
656
724
  ? list.filter((item) => item !== null && typeof item === 'object'
@@ -860,12 +928,60 @@ window.__ModuleLoader__.load({
860
928
  /** How long the undo bar keeps its offer alive after a confirmed delete. */
861
929
  const UNDO_TTL_MS = 10000
862
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
+
863
975
  function InstructionPanel() {
864
976
  const [view, setView] = React.useState(null)
865
977
  const [error, setError] = React.useState('')
866
978
  const [notice, setNotice] = React.useState('')
867
979
  const [busy, setBusy] = React.useState(false)
868
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)
869
985
  const [budgetText, setBudgetText] = React.useState('')
870
986
  // Latest live value of the draft, because the SSE onmessage closure below
871
987
  // is created once and must not read a stale first-render budgetText.
@@ -1014,6 +1130,39 @@ window.__ModuleLoader__.load({
1014
1130
  void doBatch('add-tags', tags)
1015
1131
  }
1016
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
+
1017
1166
  async function doExport() {
1018
1167
  try {
1019
1168
  const result = await callHost('export-data', null)
@@ -1152,6 +1301,15 @@ window.__ModuleLoader__.load({
1152
1301
  }
1153
1302
  const entries = sortEntries(data.entries)
1154
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)
1155
1313
  // Every tag actually in use, most frequent first (then name), for the
1156
1314
  // tag filter row.
1157
1315
  const allTags = (() => {
@@ -1271,6 +1429,63 @@ window.__ModuleLoader__.load({
1271
1429
  ? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
1272
1430
  : null)
1273
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),
1274
1489
  // F3 bulk action bar, only while in multi-select mode. "Select all"
1275
1490
  // covers the currently visible (filtered/searched) entries.
1276
1491
  (batchMode && entries.length > 0)
@@ -1378,7 +1593,7 @@ window.__ModuleLoader__.load({
1378
1593
  h('button', {
1379
1594
  className: 'im-btn im-btn-primary',
1380
1595
  disabled: busy,
1381
- onClick: () => { setDraft(blankDraft()); setError('') },
1596
+ onClick: () => { setDraft(blankDraft()); setProfileDraft(null); setError('') },
1382
1597
  }, t('action.new')),
1383
1598
  h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, t('action.reload')),
1384
1599
  h('button', { className: 'im-btn', disabled: busy, onClick: doExport }, t('action.export')),
@@ -1503,7 +1718,7 @@ window.__ModuleLoader__.load({
1503
1718
  : h('button', {
1504
1719
  className: 'im-btn',
1505
1720
  disabled: busy,
1506
- onClick: () => { setDraft(cloneEntry(entry)); setError('') },
1721
+ onClick: () => { setDraft(cloneEntry(entry)); setProfileDraft(null); setError('') },
1507
1722
  }, t('entry.edit')),
1508
1723
  batchMode
1509
1724
  ? null
package/lib/index.js CHANGED
@@ -96,6 +96,18 @@ const MAX_WHEN = 200
96
96
  const MAX_TAGS_PER_ENTRY = 8
97
97
  const MAX_TAG_LENGTH = 24
98
98
 
99
+ /**
100
+ * User-persona profiles ("用户形象"): facts about the USER (background,
101
+ * preferences, personality, goals) used to tilt how answers are framed. They
102
+ * are NOT roles the model is asked to play, and they are injected ahead of the
103
+ * instruction block under a header that demotes them to "tilt only, never
104
+ * override correctness or the question itself". They do not take part in the
105
+ * per-entry budget trimming: an identity must never be silently dropped.
106
+ */
107
+ const MAX_PROFILES = 50
108
+ const MAX_PROFILE_NAME = 40
109
+ const MAX_PROFILE_CONTENT = 4000
110
+
99
111
  /**
100
112
  * The batch operations the `batch` route accepts. Kept here so the command and
101
113
  * the route's dispatch share one list.
@@ -115,7 +127,8 @@ const BATCH_OPS = ['set-enabled', 'set-priority', 'add-tags', 'remove-tags', 'de
115
127
  * the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
116
128
  * wrapper.
117
129
  */
118
- export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200) * 2 + 100_000
130
+ export const MAX_BODY_CHARS = (MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + MAX_TAGS_PER_ENTRY * MAX_TAG_LENGTH + 200)
131
+ + MAX_PROFILES * (MAX_PROFILE_CONTENT + MAX_PROFILE_NAME + 200)) * 2 + 100_000
119
132
 
120
133
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
121
134
  const LOCK_STALE_MS = 10_000
@@ -213,6 +226,30 @@ export function sanitizeEntry(raw) {
213
226
  }
214
227
  }
215
228
 
229
+ /**
230
+ * Normalise one "用户形象" (a fact about the USER, not a role for the model).
231
+ *
232
+ * Shape: { id, name, content, enabled, updatedAt }. `name` is a short single-
233
+ * line topic label ("职业背景"、"沟通偏好"、"性格"、"长期追求"); `content` is
234
+ * the multi-line description. Both blank means the profile is discarded
235
+ * (returns null) the same way a blank entry is. A blank name falls back to the
236
+ * first 20 characters of the content so an unnamed profile is still listable.
237
+ * Exported for tests.
238
+ */
239
+ export function sanitizeProfile(raw) {
240
+ if (raw === null || typeof raw !== 'object') return null
241
+ const name = singleLine(raw.name, MAX_PROFILE_NAME)
242
+ const content = cleanText(raw.content, MAX_PROFILE_CONTENT)
243
+ if (name === '' && content === '') return null
244
+ return {
245
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeId(),
246
+ name: name === '' ? singleLine(content, 20) : name,
247
+ content,
248
+ enabled: raw.enabled !== false,
249
+ updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
250
+ }
251
+ }
252
+
216
253
  /**
217
254
  * Which fields of a raw entry had to be cut to fit the stored limits.
218
255
  *
@@ -235,6 +272,16 @@ export function truncatedFields(raw) {
235
272
  return cut
236
273
  }
237
274
 
275
+ /** Same disclosure as truncatedFields, but for a profile's name/content. */
276
+ export function truncatedProfileFields(raw) {
277
+ if (raw === null || typeof raw !== 'object') return []
278
+ const cut = []
279
+ const unlimited = Number.MAX_SAFE_INTEGER
280
+ if (typeof raw.name === 'string' && singleLine(raw.name, unlimited) !== singleLine(raw.name, MAX_PROFILE_NAME)) cut.push('形象名称')
281
+ if (typeof raw.content === 'string' && cleanText(raw.content, unlimited) !== cleanText(raw.content, MAX_PROFILE_CONTENT)) cut.push('形象内容')
282
+ return cut
283
+ }
284
+
238
285
  /** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
239
286
  const describeValue = (value) => {
240
287
  const text = JSON.stringify(value)
@@ -267,7 +314,7 @@ const describeValue = (value) => {
267
314
  * user must be told their hand edit did not survive verbatim.
268
315
  */
269
316
  function normalizeData(raw) {
270
- const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
317
+ const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] }
271
318
  if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
272
319
  if (typeof raw.version === 'number' && raw.version !== 1) {
273
320
  return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
@@ -310,7 +357,24 @@ function normalizeData(raw) {
310
357
  seen.add(entry.id)
311
358
  data.entries.push(entry)
312
359
  }
313
- return { data, dropped, optionFixes }
360
+ // User profiles ("用户形象") follow the same tolerant rules: a missing
361
+ // field on an old/hand-made file is just an empty list; blank or
362
+ // over-cap profiles are dropped and counted for disclosure, never adopted
363
+ // silently (the next save rewrites the file without them).
364
+ const profileList = Array.isArray(raw.profiles) ? raw.profiles : []
365
+ const seenProfileIds = new Set()
366
+ let droppedProfiles = 0
367
+ for (const candidate of profileList) {
368
+ const profile = sanitizeProfile(candidate)
369
+ if (profile === null || data.profiles.length >= MAX_PROFILES) {
370
+ droppedProfiles += 1
371
+ continue
372
+ }
373
+ if (seenProfileIds.has(profile.id)) profile.id = makeId()
374
+ seenProfileIds.add(profile.id)
375
+ data.profiles.push(profile)
376
+ }
377
+ return { data, dropped, droppedProfiles, optionFixes }
314
378
  }
315
379
 
316
380
  /**
@@ -329,6 +393,7 @@ function cloneData(data) {
329
393
  enabled: data.enabled === true,
330
394
  budgetChars: data.budgetChars,
331
395
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
396
+ profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
332
397
  }
333
398
  }
334
399
 
@@ -337,6 +402,11 @@ const droppedEntriesMessage = (count) =>
337
402
  ? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
338
403
  : null
339
404
 
405
+ const droppedProfilesMessage = (count) =>
406
+ count > 0
407
+ ? '存储文件中有 ' + count + ' 个用户形象未能载入(名称与内容同时为空或超过 ' + MAX_PROFILES + ' 个上限);下次保存时它们将从文件中移除'
408
+ : null
409
+
340
410
  /**
341
411
  * The load-time disclosures for one normalizeData result: dropped entries and
342
412
  * corrected hand-edited options, joined with a full-width semicolon.
@@ -347,6 +417,8 @@ const loadWarnings = (loaded) => {
347
417
  const parts = []
348
418
  const dropped = droppedEntriesMessage(loaded.dropped)
349
419
  if (dropped !== null) parts.push(dropped)
420
+ const droppedProfiles = droppedProfilesMessage(loaded.droppedProfiles || 0)
421
+ if (droppedProfiles !== null) parts.push(droppedProfiles)
350
422
  if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
351
423
  parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
352
424
  }
@@ -582,9 +654,53 @@ function formatEntry(entry, tag) {
582
654
  * competing for the budget at all. When nothing renders, `text` is '' and
583
655
  * `included` is [] even though `budget` still reports the effective ceiling.
584
656
  */
585
- export function planInjection(data) {
586
- const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
587
- const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
657
+ /**
658
+ * The persona ("用户形象") preamble. One fixed header whether one or several
659
+ * profiles are active: each is a SIDE of the same user (background, tastes,
660
+ * personality, goals), so there is no "play multiple roles" framing. The
661
+ * wording deliberately demotes the whole section to a tilt on framing —
662
+ * correctness, completeness and the question itself always win.
663
+ */
664
+ const PERSONA_HEADER = [
665
+ '## 关于用户(用户形象)',
666
+ '以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。',
667
+ '',
668
+ ].join('\n')
669
+
670
+ /**
671
+ * Build the persona section from enabled profiles with non-empty content, in
672
+ * stored order. Returns the rendered text plus one { id, name, chars } item
673
+ * per rendered profile, in the same shape the snapshot exposes. Deterministic:
674
+ * no timestamps, no counts, identical profiles in produce byte-identical
675
+ * text. An empty/disabled set renders to '' so callers can join unconditionally.
676
+ */
677
+ function buildProfilesBlock(profiles) {
678
+ const active = (Array.isArray(profiles) ? profiles : [])
679
+ .filter((profile) => profile !== null && typeof profile === 'object'
680
+ && profile.enabled === true
681
+ && typeof profile.content === 'string' && profile.content !== '')
682
+ if (active.length === 0) return { text: '', profiles: [], chars: 0 }
683
+ const blocks = []
684
+ const items = []
685
+ for (const profile of active) {
686
+ const name = typeof profile.name === 'string' && profile.name !== ''
687
+ ? profile.name
688
+ : singleLine(profile.content, 20)
689
+ const block = '### ' + name + '\n' + profile.content
690
+ blocks.push(block)
691
+ items.push({ id: typeof profile.id === 'string' ? profile.id : '', name, chars: block.length })
692
+ }
693
+ const text = PERSONA_HEADER + '\n' + blocks.join('\n\n')
694
+ return { text, profiles: items, chars: text.length }
695
+ }
696
+
697
+ /**
698
+ * The entry half of the plan: the existing instruction block with its budget
699
+ * trimming. Kept as the inner function so planInjection can prepend the
700
+ * persona section without touching (and risking) the trimming logic.
701
+ */
702
+ function planEntries(data, budget) {
703
+ const blank = () => ({ text: '', activeCount: 0, included: [], omitted: [] })
588
704
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
589
705
  const active = (Array.isArray(data.entries) ? data.entries : [])
590
706
  .filter((entry) => entry.enabled === true && entry.content !== '')
@@ -679,6 +795,48 @@ export function planInjection(data) {
679
795
  }
680
796
  }
681
797
 
798
+ /**
799
+ * Compose the full injected section: the persona block ("关于用户", when at
800
+ * least one profile is enabled) followed by the existing instruction block.
801
+ *
802
+ * Returned shape extends the entry plan with:
803
+ * profiles: [{ id, name, chars }] — rendered persona profiles, in order
804
+ * profileChars: total characters of the persona section (0 when none)
805
+ * entryChars: length of the entry half alone
806
+ *
807
+ * Guarantees:
808
+ * - zero enabled profiles => the result is byte-identical to the old
809
+ * entries-only plan (existing tests pin this);
810
+ * - personas are NOT subject to the entry budget — the budget/included/
811
+ * omitted fields still describe instructions only, and an identity is
812
+ * never silently trimmed;
813
+ * - a profile-only store (no instructions) still injects, and the master
814
+ * `enabled` switch suppresses both halves as before.
815
+ */
816
+ export function planInjection(data) {
817
+ const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
818
+ const blank = () => ({
819
+ text: '', budget, activeCount: 0, included: [], omitted: [],
820
+ profiles: [], profileChars: 0, entryChars: 0,
821
+ })
822
+ if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
823
+ const persona = buildProfilesBlock(data.profiles)
824
+ const entryPlan = planEntries(data, budget)
825
+ const parts = []
826
+ if (persona.text !== '') parts.push(persona.text)
827
+ if (entryPlan.text !== '') parts.push(entryPlan.text)
828
+ return {
829
+ text: parts.join('\n\n'),
830
+ budget,
831
+ activeCount: entryPlan.activeCount,
832
+ included: entryPlan.included,
833
+ omitted: entryPlan.omitted,
834
+ entryChars: entryPlan.text.length,
835
+ profiles: persona.profiles,
836
+ profileChars: persona.chars,
837
+ }
838
+ }
839
+
682
840
  /**
683
841
  * Render the injected block text. A thin wrapper over planInjection: the
684
842
  * decision process lives in one place, and callers that only need the text
@@ -696,7 +854,7 @@ export function apply(ctx) {
696
854
  const systemPrompt = ctx.systemPrompt
697
855
 
698
856
  const state = {
699
- data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
857
+ data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [], profiles: [] },
700
858
  storagePath: null,
701
859
  storageDisplay: null,
702
860
  storageError: null,
@@ -819,7 +977,10 @@ export function apply(ctx) {
819
977
  return planInjection(state.data)
820
978
  } catch (err) {
821
979
  console.error('[instruction-memory] render failed:', err)
822
- return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
980
+ return {
981
+ text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [],
982
+ profiles: [], profileChars: 0, entryChars: 0,
983
+ }
823
984
  }
824
985
  }
825
986
 
@@ -1394,6 +1555,7 @@ export function apply(ctx) {
1394
1555
  enabled: state.data.enabled === true,
1395
1556
  budgetChars: clampBudget(state.data.budgetChars),
1396
1557
  entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1558
+ profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
1397
1559
  },
1398
1560
  storage: {
1399
1561
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
@@ -1419,6 +1581,15 @@ export function apply(ctx) {
1419
1581
  activeCount: plan.activeCount,
1420
1582
  included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
1421
1583
  omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
1584
+ /**
1585
+ * Persona section detail: rendered profiles (id/name/chars, in order)
1586
+ * and their total size. `entryChars` is the instruction half alone,
1587
+ * which is what the budget meter measures; `chars` above stays the
1588
+ * whole section (personas + instructions) for diagnostics.
1589
+ */
1590
+ profiles: plan.profiles.map((item) => ({ id: item.id, name: item.name, chars: item.chars })),
1591
+ profileChars: plan.profileChars,
1592
+ entryChars: plan.entryChars,
1422
1593
  /**
1423
1594
  * How many times the harness has pulled this section into a prompt since
1424
1595
  * boot. 0 means "prepared, never assembled" — the distinction the old
@@ -1433,7 +1604,15 @@ export function apply(ctx) {
1433
1604
  * into memory. It is a character budget against byte sizes, so the
1434
1605
  * Client applies a generous multiplier — see importSizeVerdict there.
1435
1606
  */
1436
- limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
1607
+ limits: {
1608
+ minBudget: MIN_BUDGET,
1609
+ maxBudget: MAX_BUDGET,
1610
+ maxEntries: MAX_ENTRIES,
1611
+ maxProfiles: MAX_PROFILES,
1612
+ maxProfileName: MAX_PROFILE_NAME,
1613
+ maxProfileContent: MAX_PROFILE_CONTENT,
1614
+ maxBodyChars: MAX_BODY_CHARS,
1615
+ },
1437
1616
  }
1438
1617
  }
1439
1618
 
@@ -1697,6 +1876,42 @@ export function apply(ctx) {
1697
1876
  return settle(done + tail + missingNote, saved)
1698
1877
  })
1699
1878
 
1879
+ const saveProfile = (raw, baseRev) => enqueue(async () => {
1880
+ if (state.versionRejected) return versionRefusal()
1881
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
1882
+ const profile = sanitizeProfile(raw)
1883
+ if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
1884
+ profile.updatedAt = Date.now()
1885
+ const replacesExisting = state.data.profiles.some((candidate) => candidate.id === profile.id)
1886
+ if (!replacesExisting && state.data.profiles.length >= MAX_PROFILES) {
1887
+ return { ok: false, saved: false, message: '最多保存 ' + MAX_PROFILES + ' 个用户形象', snapshot: snapshot() }
1888
+ }
1889
+ const cut = truncatedProfileFields(raw)
1890
+ const saved = await commit(() => {
1891
+ const list = state.data.profiles
1892
+ const index = list.findIndex((candidate) => candidate.id === profile.id)
1893
+ if (index >= 0) list[index] = profile
1894
+ else list.push(profile)
1895
+ })
1896
+ const note = cut.length > 0
1897
+ ? '已保存;' + cut.join('、') + '超过长度上限(名称 ' + MAX_PROFILE_NAME + ' / 内容 ' + MAX_PROFILE_CONTENT + ' 字符),超出部分已被截断'
1898
+ : null
1899
+ return settle(note, saved)
1900
+ })
1901
+
1902
+ const deleteProfile = (id, baseRev) => enqueue(async () => {
1903
+ if (state.versionRejected) return versionRefusal()
1904
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
1905
+ // Mirror deleteEntry: an absent id changes nothing and must not touch disk.
1906
+ if (!state.data.profiles.some((profile) => profile.id === id)) {
1907
+ return { ok: true, saved: false, message: null, snapshot: snapshot() }
1908
+ }
1909
+ const saved = await commit(() => {
1910
+ state.data.profiles = state.data.profiles.filter((profile) => profile.id !== id)
1911
+ })
1912
+ return settle(null, saved)
1913
+ })
1914
+
1700
1915
  const reload = () => enqueue(async () => {
1701
1916
  // `saved` is only true when the load itself wrote to disk (the
1702
1917
  // backup-promotion or legacy-migration branch); a plain re-read claiming
@@ -1705,7 +1920,6 @@ export function apply(ctx) {
1705
1920
  syncSection()
1706
1921
  return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
1707
1922
  })
1708
-
1709
1923
  const exportData = () => enqueue(async () => ({
1710
1924
  ok: true,
1711
1925
  // An export reads the store and never writes it; a truthful `saved:false`
@@ -1724,15 +1938,16 @@ export function apply(ctx) {
1724
1938
  if (payload === null || typeof payload !== 'object') {
1725
1939
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
1726
1940
  }
1727
- // Accept both the plugin's own export envelope ({app, data:{entries}}) and
1728
- // a bare {entries} array (e.g. a hand-assembled file).
1729
- const rawEntries = Array.isArray(payload.entries)
1730
- ? payload.entries
1731
- : (payload.data !== null && typeof payload.data === 'object' && Array.isArray(payload.data.entries)
1732
- ? payload.data.entries
1733
- : null)
1734
- if (rawEntries === null || rawEntries.length === 0) {
1735
- return { ok: false, saved: false, message: '导入文件中没有找到条目', snapshot: snapshot() }
1941
+ // Accept both the plugin's own export envelope ({app, data:{entries,…}})
1942
+ // and a bare {entries}/{profiles} object (e.g. a hand-assembled file).
1943
+ const inner = payload.data !== null && typeof payload.data === 'object' ? payload.data : payload
1944
+ const rawEntries = Array.isArray(payload.entries) ? payload.entries
1945
+ : (Array.isArray(inner.entries) ? inner.entries : null)
1946
+ const rawProfiles = Array.isArray(payload.profiles) ? payload.profiles
1947
+ : (Array.isArray(inner.profiles) ? inner.profiles : null)
1948
+ if ((rawEntries === null || rawEntries.length === 0)
1949
+ && (rawProfiles === null || rawProfiles.length === 0)) {
1950
+ return { ok: false, saved: false, message: '导入文件中没有找到条目或用户形象', snapshot: snapshot() }
1736
1951
  }
1737
1952
  // Merge, never replace: importing must not be able to wipe what is here.
1738
1953
  // Duplicates (same id, or same title+content) and invalid items are
@@ -1747,7 +1962,7 @@ export function apply(ctx) {
1747
1962
  const cutFields = new Set()
1748
1963
  let truncatedCount = 0
1749
1964
  let skipped = 0
1750
- for (const candidate of rawEntries) {
1965
+ for (const candidate of (rawEntries || [])) {
1751
1966
  const entry = sanitizeEntry(candidate)
1752
1967
  if (entry === null || seenIds.has(entry.id) || seenBodies.has(entry.title + '\u0000' + entry.content)) {
1753
1968
  skipped += 1
@@ -1771,18 +1986,70 @@ export function apply(ctx) {
1771
1986
  seenIds.add(entry.id)
1772
1987
  seenBodies.add(entry.title + '\u0000' + entry.content)
1773
1988
  }
1774
- if (accepted.length === 0) {
1989
+ // Persona profiles merge under the same rules (same id, or same
1990
+ // name+content) in the SAME transaction, so a mixed import never lands
1991
+ // half-way.
1992
+ const existingProfiles = state.data.profiles
1993
+ const seenProfileIds = new Set(existingProfiles.map((profile) => profile.id))
1994
+ const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.name + '\u0000' + profile.content))
1995
+ const acceptedProfiles = []
1996
+ const cutProfileFields = new Set()
1997
+ let truncatedProfileCount = 0
1998
+ let skippedProfiles = 0
1999
+ for (const candidate of (rawProfiles || [])) {
2000
+ const profile = sanitizeProfile(candidate)
2001
+ if (profile === null
2002
+ || seenProfileIds.has(profile.id)
2003
+ || seenProfileBodies.has(profile.name + '\u0000' + profile.content)) {
2004
+ skippedProfiles += 1
2005
+ continue
2006
+ }
2007
+ if (existingProfiles.length + acceptedProfiles.length >= MAX_PROFILES) {
2008
+ skippedProfiles += 1
2009
+ continue
2010
+ }
2011
+ const cut = truncatedProfileFields(candidate)
2012
+ if (cut.length > 0) {
2013
+ truncatedProfileCount += 1
2014
+ for (const field of cut) cutProfileFields.add(field)
2015
+ }
2016
+ profile.updatedAt = Date.now()
2017
+ acceptedProfiles.push(profile)
2018
+ seenProfileIds.add(profile.id)
2019
+ seenProfileBodies.add(profile.name + '\u0000' + profile.content)
2020
+ }
2021
+ if (accepted.length === 0 && acceptedProfiles.length === 0) {
1775
2022
  // Nothing was written to disk, so claiming saved:true would be a lie the
1776
2023
  // panel cannot distinguish from a real save.
1777
- return { ok: true, saved: false, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
2024
+ return {
2025
+ ok: true,
2026
+ saved: false,
2027
+ message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效',
2028
+ snapshot: snapshot(),
2029
+ }
1778
2030
  }
1779
2031
  const saved = await commit(() => {
1780
2032
  state.data.entries.push(...accepted)
2033
+ state.data.profiles.push(...acceptedProfiles)
1781
2034
  })
1782
- const truncated = truncatedCount > 0
1783
- ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
1784
- : ''
1785
- return settle('导入完成:新增 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条(已存在或无效)' + truncated, saved)
2035
+ const parts = []
2036
+ if (accepted.length > 0) {
2037
+ const truncated = truncatedCount > 0
2038
+ ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
2039
+ : ''
2040
+ parts.push('新增指令 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
2041
+ } else if (skipped > 0) {
2042
+ parts.push('指令跳过 ' + skipped + ' 条(已存在或无效)')
2043
+ }
2044
+ if (acceptedProfiles.length > 0) {
2045
+ const truncated = truncatedProfileCount > 0
2046
+ ? ';其中 ' + truncatedProfileCount + ' 个超过长度上限,' + [...cutProfileFields].join('、') + '已被截断'
2047
+ : ''
2048
+ parts.push('新增用户形象 ' + acceptedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
2049
+ } else if (skippedProfiles > 0) {
2050
+ parts.push('用户形象跳过 ' + skippedProfiles + ' 个(已存在或无效)')
2051
+ }
2052
+ return settle('导入完成:' + parts.join(';'), saved)
1786
2053
  })
1787
2054
 
1788
2055
  const dispatch = async (method, args) => {
@@ -1825,6 +2092,14 @@ export function apply(ctx) {
1825
2092
  args !== null && typeof args === 'object' ? args.ids : null,
1826
2093
  args !== null && typeof args === 'object' ? args.value : undefined,
1827
2094
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
2095
+ case 'save-profile':
2096
+ return saveProfile(
2097
+ args !== null && typeof args === 'object' ? args.profile : null,
2098
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
2099
+ case 'delete-profile':
2100
+ return deleteProfile(
2101
+ args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
2102
+ args !== null && typeof args === 'object' ? args.baseRev : undefined)
1828
2103
  default:
1829
2104
  return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
1830
2105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.18",
3
+ "version": "1.1.0",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",