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 +32 -0
- package/README.md +22 -1
- package/lib/client.js +218 -3
- package/lib/index.js +301 -26
- package/package.json +1 -1
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 {
|
|
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: {
|
|
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}})
|
|
1728
|
-
// a bare {entries}
|
|
1729
|
-
const
|
|
1730
|
-
|
|
1731
|
-
: (
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
if (rawEntries === null || rawEntries.length === 0)
|
|
1735
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
|
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",
|