dsh-agent-persona 0.1.5 → 0.1.7
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 +43 -0
- package/README.md +19 -5
- package/README_EN.md +20 -4
- package/lib/client.js +182 -6
- package/lib/endpoints.d.ts +1 -1
- package/lib/endpoints.js +3 -0
- package/lib/index.d.ts +41 -0
- package/lib/index.js +346 -0
- package/lib/remote.js +3 -0
- package/lib/typert.js +51 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.1.7] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **开关的 toast 只讲自己那件事。** 提示文案原来只对两个显示开关按 `patch` 取,其余一律按
|
|
12
|
+
`next.autosave` 计算,于是拨动「禁止模型改写人设文件」或「保存人设版本历史」时弹出来的是
|
|
13
|
+
「编辑后自动保存」——一个这次根本没碰的开关的话。现在每个偏好各有一条按方向分岔的句子
|
|
14
|
+
(`PREF_STATUS`),键从 `patch` 上读,未知键退化成「已保存」。
|
|
15
|
+
- **同一句提示连拨两次也会再弹一次。** toast 原来用固定 `key`,第二次拨回同一状态时 React 复用
|
|
16
|
+
同一个组件实例:文案一样、key 一样,定时器继续走,屏幕上看不到新提示。现在 `status` 每次写入都
|
|
17
|
+
推进一个 `statusSeq`,toast 按它做 key,重复的句子也会重新挂载。这也是「开启没反应、关闭才弹」
|
|
18
|
+
的成因之一:两个方向的文案本来就相同,第二次写入被吞掉了。
|
|
19
|
+
- 顺带把「关闭自动保存」的文案从「已关闭:编辑后自动保存」改成「已关闭:改完要自己点保存」。原文案
|
|
20
|
+
在关闭状态下仍然宣称会自动保存。
|
|
21
|
+
|
|
22
|
+
## [0.1.6] - 2026-10-22
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **人设版本历史(可选开关)。** 设置页新增「保存人设版本历史」,默认关闭。打开后每次保存正文都会
|
|
27
|
+
追加一条 diff 到 `personas.history.json`(与主存储同目录、0600):相等行只记行数,变更行原文保留,
|
|
28
|
+
因此一条记录的成本约等于这次改动本身,而不是整份正文。
|
|
29
|
+
- 编辑器底部新增「版本历史」按钮:左侧列出版本(时间、`+a / −r`、首个改动行),右侧显示该版相对上一版的
|
|
30
|
+
逐行 diff(红删绿增,未改动段折叠成行数)。「恢复到此版本」把选中版本写回当前人设,并且恢复本身也记成
|
|
31
|
+
一条新记录,所以恢复错了还能再回滚。
|
|
32
|
+
- 新 RPC:`listVersions` / `versionDiff` / `restoreVersion`(远程产物由 `npm run generate:remote` 生成)。
|
|
33
|
+
- 链式存储:第一条版本作为 base 快照,之后每版只在上一版上重放 diff;超过 `MAX_VERSIONS`(200)时把最旧的
|
|
34
|
+
几条折进 base,保证保留下来的版本仍可逐条重放。每条记录带内容 sha 摘要,历史被手改或损坏时重放会报错
|
|
35
|
+
而不是静默给出别的正文。
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- README 里的截图全部重作为动图(设置列表、卡片编辑、版本历史演示、会话页「人设」标签),
|
|
40
|
+
都取自 0.1.6 的实机操作:版本历史那张演示「打开 → 选较早版本 → 恢复」的完整回路。
|
|
41
|
+
同时去掉了与动图重复的静态 `versions.png`。
|
|
42
|
+
- 兼容性不变:仍按 0.1.5 的适配结论跑在 DSH `0.2.1-rc.2` 上(`dshReleases` 里的
|
|
43
|
+
`0.1.5-rc.1` / `0.1.5-rc.2` / `0.2.0-rc.2` / `0.2.1-rc.2` 标记不需要改动)。
|
|
44
|
+
|
|
45
|
+
### Notes
|
|
46
|
+
|
|
47
|
+
- 历史只记正文,不记名称、启用状态与适用范围;关掉开关后不再写新记录,已有历史仍可查看与恢复。
|
|
48
|
+
- 版本历史是独立于守卫的开关:守卫管的是「工具不许改存储」,历史管的是「改了就留痕」,两者互不依赖。
|
|
49
|
+
|
|
7
50
|
## [0.1.5] - 2026-10-20
|
|
8
51
|
|
|
9
52
|
### Changed
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
| **会话视图** | 对话页顶部的 **人设** 标签 | 看这条会话命中了哪条人设、**实际发出去的 system prompt**(渲染/源码两视图),以及就地管理全部人设 |
|
|
38
38
|
| **侧边栏卡片** | dsh-better-sidebar 的右侧栏 | 把上面那个「人设」页面挂进右侧栏(需要装了该插件) |
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
五个偏好开关(`编辑后自动保存` / `在对话页显示「人设」标签` / `注册到 dsh-better-sidebar` / `禁止模型改写人设文件` / `保存人设版本历史`)
|
|
41
41
|
都在**设置 → Agent 人设**页里,并且这个页面**不受这些开关影响** —— 关掉「对话页显示」或「注册到 dsh-better-sidebar」后,
|
|
42
42
|
回到这里就能重新打开。本插件不在**设置 → 通用设置**里添加任何条目;鼠标停在开关上会给出每个开关的完整说明。
|
|
43
43
|
|
|
@@ -83,7 +83,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
83
83
|
|
|
84
84
|
## 用起来
|
|
85
85
|
|
|
86
|
-

|
|
87
87
|
|
|
88
88
|
点 **新建人设** → 在「适用范围」里加行,选工作区或会话 → 写正文 → 打开启用开关 → 保存。
|
|
89
89
|
|
|
@@ -99,7 +99,21 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
99
99
|
|
|
100
100
|
展开一张卡是这样:
|
|
101
101
|
|
|
102
|
-

|
|
103
|
+
|
|
104
|
+
### 版本历史(可选)
|
|
105
|
+
|
|
106
|
+
打开设置页的 **保存人设版本历史**,之后每次保存都会把这次的正文改动记成一条 diff(写在
|
|
107
|
+
`personas.history.json`)。编辑器底部的 **版本历史** 按钮打开对比窗口:
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+
|
|
111
|
+
- **只记正文**:名称、启用状态、适用范围不参与版本,改这些不会产生一条记录。
|
|
112
|
+
- **每条都是 diff**:相等行只记行数,只有真正改动的行才存原文,所以一百条版本也远小于一百份正文。
|
|
113
|
+
- **恢复**:选中任意一条点「恢复到此版本」写回正文;恢复动作本身也记一条,恢复错了还能再回滚。
|
|
114
|
+
- **链式重放**:第一条版本作为基准快照,之后逐条重放;超过 200 条时把最旧的几条折进基准,
|
|
115
|
+
保留下来的版本仍可逐条打开。每条记录带内容摘要,历史文件被手改坏时重放会报错,不会给出别的正文。
|
|
116
|
+
- 关掉开关只是停止记录,已有历史仍可查看与恢复。
|
|
103
117
|
|
|
104
118
|
### 看这条会话聊了什么
|
|
105
119
|
|
|
@@ -113,7 +127,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
113
127
|
|
|
114
128
|
### 对话页的「人设」标签
|
|
115
129
|
|
|
116
|
-

|
|
117
131
|
|
|
118
132
|
选中一条会话后,对话页顶部有 **人设 / 提示词 / 管理** 三个面板:
|
|
119
133
|
|
|
@@ -124,7 +138,7 @@ dsh plugin --profile web remove dsh-agent-persona # 卸载
|
|
|
124
138
|
没有命中任何人设时,这里显示的就是 DSH 原生注入的 prompt —— 不做任何特殊处理。
|
|
125
139
|
- **管理**:与设置页同一套卡片(新建、启用/停用、编辑、排序、复制、删除),就地管理。
|
|
126
140
|
|
|
127
|
-
###
|
|
141
|
+
### 五个偏好
|
|
128
142
|
|
|
129
143
|
| 开关 | 默认 | 含义 | 生效时机 |
|
|
130
144
|
|---|---|---|---|
|
package/README_EN.md
CHANGED
|
@@ -95,7 +95,7 @@ overwritten — uninstalling only removes the plugin.
|
|
|
95
95
|
|
|
96
96
|
## Using it
|
|
97
97
|
|
|
98
|
-

|
|
99
99
|
|
|
100
100
|
**New persona** → add rows under reach, pick a workspace or a session → write the body → switch it on → save.
|
|
101
101
|
|
|
@@ -109,7 +109,22 @@ overwritten — uninstalling only removes the plugin.
|
|
|
109
109
|
|
|
110
110
|
An expanded card — name, state, the default switch, injection mode, the reach rules, the body and the footer:
|
|
111
111
|
|
|
112
|
-

|
|
113
|
+
|
|
114
|
+
### Version history (opt-in)
|
|
115
|
+
|
|
116
|
+
Turn on **Save persona version history** in Settings and every save records the text change as a diff in
|
|
117
|
+
`personas.history.json`. The version-history button in the editor toolbar opens the comparison:
|
|
118
|
+
|
|
119
|
+

|
|
120
|
+
|
|
121
|
+
- **Text only**: name, enabled flag and targets are not versioned.
|
|
122
|
+
- **Diffs, not snapshots**: equal runs are stored as line counts; only changed lines keep their text.
|
|
123
|
+
- **Restore**: pick any version and restore it; the restore itself is recorded, so it can be undone too.
|
|
124
|
+
- **Chained replay**: the first text is the base snapshot, later versions replay on top of it; past 200
|
|
125
|
+
versions the oldest are folded into the base. A content hash per record makes a hand-edited history fail
|
|
126
|
+
loudly instead of restoring different text.
|
|
127
|
+
- Turning the switch off stops recording; existing history stays readable and restorable.
|
|
113
128
|
|
|
114
129
|
### Reading what a session actually said
|
|
115
130
|
|
|
@@ -125,7 +140,7 @@ replies on the left.
|
|
|
125
140
|
|
|
126
141
|
### The 人设 tab on a conversation
|
|
127
142
|
|
|
128
|
-

|
|
129
144
|
|
|
130
145
|
**人设 / 提示词 / 管理**:
|
|
131
146
|
|
|
@@ -136,7 +151,7 @@ replies on the left.
|
|
|
136
151
|
persona matched it shows DSH's own prompt verbatim.
|
|
137
152
|
- **管理** — the same cards as the settings page, in place.
|
|
138
153
|
|
|
139
|
-
### The
|
|
154
|
+
### The five preferences
|
|
140
155
|
|
|
141
156
|
| Switch | Default | Meaning | Takes effect |
|
|
142
157
|
|---|---|---|---|
|
|
@@ -144,6 +159,7 @@ replies on the left.
|
|
|
144
159
|
| 在对话页显示「人设」标签 | on | Register the conversation view or not | Immediately (registered/unregistered at runtime) |
|
|
145
160
|
| 注册到 dsh-better-sidebar | on | Register the card with the sidebar plugin | Immediately; **not shown at all when that plugin is absent** |
|
|
146
161
|
| 禁止模型改写人设文件 | on | Refuse the model's file and shell tools any write to or removal of the persona store (reads still work); with it off the model can edit it directly | Immediately, on the next tool call |
|
|
162
|
+
| 保存人设版本历史 | off | Record every text change as a diff in `personas.history.json`, so any version can be compared and restored | Immediately, on the next save |
|
|
147
163
|
|
|
148
164
|
Preferences live in the persona file's `prefs` field — not in the browser — so they share the data's scope. The
|
|
149
165
|
browser keeps only a mirror, used to decide at load whether to register the views.
|
package/lib/client.js
CHANGED
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
method("sessionPrompt", withInput()),
|
|
45
45
|
method("tunePersona", withInput()),
|
|
46
46
|
method("listModels", []),
|
|
47
|
-
method("savePrefs", withInput())
|
|
47
|
+
method("savePrefs", withInput()),
|
|
48
|
+
method("listVersions", withInput()),
|
|
49
|
+
method("versionDiff", withInput()),
|
|
50
|
+
method("restoreVersion", withInput())
|
|
48
51
|
]
|
|
49
52
|
};
|
|
50
53
|
|
|
@@ -226,10 +229,21 @@
|
|
|
226
229
|
return { ...DEFAULT_PREFS };
|
|
227
230
|
}
|
|
228
231
|
};
|
|
229
|
-
const
|
|
232
|
+
const PREF_STATUS = {
|
|
233
|
+
autosave: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u7F16\u8F91\u540E\u505C\u6B62\u8F93\u5165\u7EA6 1 \u79D2\u5373\u81EA\u52A8\u4FDD\u5B58" : "\u5DF2\u5173\u95ED\uFF1A\u6539\u5B8C\u8981\u81EA\u5DF1\u70B9\u4FDD\u5B58",
|
|
234
|
+
showTab: (on) => on ? "\u5DF2\u663E\u793A\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u51FA\u73B0\uFF09" : "\u5DF2\u9690\u85CF\u300C\u4EBA\u8BBE\u300D\u6807\u7B7E\uFF08\u5237\u65B0\u9875\u9762\u540E\u751F\u6548\uFF09",
|
|
235
|
+
showSidebar: (on) => on ? "\u5DF2\u6CE8\u518C\u5230 dsh-better-sidebar" : "\u5DF2\u4ECE dsh-better-sidebar \u6CE8\u9500",
|
|
236
|
+
guard: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6A21\u578B\u7684\u5199\u5165\u4E0E\u547D\u4EE4\u5DE5\u5177\u4E0D\u80FD\u518D\u6539\u4EBA\u8BBE\u5B58\u50A8" : "\u5DF2\u5173\u95ED\uFF1A\u6A21\u578B\u53EF\u4EE5\u76F4\u63A5\u6539\u5199\u4EBA\u8BBE\u5B58\u50A8",
|
|
237
|
+
history: (on) => on ? "\u5DF2\u5F00\u542F\uFF1A\u6BCF\u6B21\u4FDD\u5B58\u90FD\u4F1A\u8BB0\u4E00\u6761\u7248\u672C\u5386\u53F2" : "\u5DF2\u5173\u95ED\uFF1A\u4E0D\u518D\u8BB0\u65B0\u7248\u672C\uFF0C\u5DF2\u6709\u5386\u53F2\u4ECD\u53EF\u67E5\u770B\u4E0E\u6062\u590D"
|
|
238
|
+
};
|
|
239
|
+
const prefStatus = (patch) => {
|
|
240
|
+
const key = Object.keys(patch ?? {}).find((name) => Object.prototype.hasOwnProperty.call(PREF_STATUS, name));
|
|
241
|
+
if (key === void 0) return "\u5DF2\u4FDD\u5B58";
|
|
242
|
+
return PREF_STATUS[key](patch[key] === true);
|
|
243
|
+
};
|
|
230
244
|
const persistPrefs = (api, unwrap, current, patch, report) => {
|
|
231
245
|
const next = writePrefs({ ...current, ...patch });
|
|
232
|
-
report({ prefs: next, status: prefStatus(
|
|
246
|
+
report({ prefs: next, status: prefStatus(patch) });
|
|
233
247
|
syncSidebarModule();
|
|
234
248
|
if (api === void 0 || typeof api.savePrefs !== "function") return next;
|
|
235
249
|
void api.savePrefs(next).then((result) => {
|
|
@@ -723,6 +737,22 @@
|
|
|
723
737
|
@media (prefers-reduced-motion: reduce) {
|
|
724
738
|
[data-plugin="dsh-agent-persona"] * { transition: none !important; }
|
|
725
739
|
}
|
|
740
|
+
|
|
741
|
+
.wsp-versions { display: flex; flex-direction: column; gap: 12px; max-height: 58vh; }
|
|
742
|
+
.wsp-versions-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
|
743
|
+
.wsp-version-row { display: flex; flex-direction: column; gap: 2px; text-align: left; min-width: 150px; max-width: 150px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--wsp-line); background: transparent; color: inherit; cursor: pointer; font: inherit; }
|
|
744
|
+
.wsp-version-row:hover { border-color: var(--wsp-line-strong); }
|
|
745
|
+
.wsp-version-row.is-active { border-color: var(--dsw-alias-brand-primary, #4f8cff); background: var(--dsw-alias-bg-l2, rgba(79, 140, 255, 0.08)); }
|
|
746
|
+
.wsp-version-title { font-size: 13px; font-weight: 600; }
|
|
747
|
+
.wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
|
|
748
|
+
.wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
749
|
+
.wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
|
|
750
|
+
.wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
|
|
751
|
+
.wsp-diff-body { overflow: auto; border: 1px solid var(--wsp-line); border-radius: 8px; padding: 8px 0; background: var(--wsp-line-soft); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.55; }
|
|
752
|
+
.wsp-diff-line { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
|
|
753
|
+
.wsp-diff-add { background: rgba(46, 160, 67, 0.16); }
|
|
754
|
+
.wsp-diff-del { background: rgba(248, 81, 73, 0.14); }
|
|
755
|
+
.wsp-diff-skip { padding: 2px 10px; opacity: 0.5; font-size: 11px; }
|
|
726
756
|
`;
|
|
727
757
|
let styleElement;
|
|
728
758
|
const injectStyles = () => {
|
|
@@ -1153,6 +1183,11 @@
|
|
|
1153
1183
|
dropIndex: void 0,
|
|
1154
1184
|
history: void 0,
|
|
1155
1185
|
historyBusy: void 0,
|
|
1186
|
+
versions: void 0,
|
|
1187
|
+
versionsBusy: "",
|
|
1188
|
+
versionsError: "",
|
|
1189
|
+
versionDiff: void 0,
|
|
1190
|
+
versionIndex: void 0,
|
|
1156
1191
|
models: [],
|
|
1157
1192
|
model: "",
|
|
1158
1193
|
tuneOpen: false,
|
|
@@ -1163,6 +1198,10 @@
|
|
|
1163
1198
|
busy: "",
|
|
1164
1199
|
error: "",
|
|
1165
1200
|
status: "",
|
|
1201
|
+
// Bumped on every status write. The toast is keyed on it, so the same sentence twice in a
|
|
1202
|
+
// row (toggling one switch off and on again) remounts instead of silently extending the
|
|
1203
|
+
// toast that is already on screen.
|
|
1204
|
+
statusSeq: 0,
|
|
1166
1205
|
transfer: void 0
|
|
1167
1206
|
};
|
|
1168
1207
|
this.mounted = false;
|
|
@@ -1171,8 +1210,15 @@
|
|
|
1171
1210
|
this.importTextRef = React.createRef();
|
|
1172
1211
|
this.importHighlightRef = React.createRef();
|
|
1173
1212
|
this.importTimer = void 0;
|
|
1213
|
+
this.statusSeq = 0;
|
|
1174
1214
|
this.onChange = (patch) => {
|
|
1175
|
-
if (this.mounted)
|
|
1215
|
+
if (!this.mounted) return;
|
|
1216
|
+
if (patch !== null && typeof patch === "object" && "status" in patch) {
|
|
1217
|
+
this.statusSeq += 1;
|
|
1218
|
+
this.setState({ ...patch, statusSeq: this.statusSeq });
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
this.setState(patch);
|
|
1176
1222
|
};
|
|
1177
1223
|
this.onKeyDown = (event) => {
|
|
1178
1224
|
if (!(event.metaKey || event.ctrlKey) || String(event.key).toLowerCase() !== "s") return;
|
|
@@ -1323,6 +1369,55 @@
|
|
|
1323
1369
|
}
|
|
1324
1370
|
return items;
|
|
1325
1371
|
}
|
|
1372
|
+
/** Version history: list, diff, restore. Opt-in through the settings switch. */
|
|
1373
|
+
async openVersions(id) {
|
|
1374
|
+
const api = this.api();
|
|
1375
|
+
if (api === void 0 || typeof api.listVersions !== "function") {
|
|
1376
|
+
this.onChange({ versionsError: "\u8FD9\u4E2A\u7248\u672C\u8FD8\u6CA1\u6709\u7248\u672C\u5386\u53F2\u63A5\u53E3\uFF1A\u8BF7\u66F4\u65B0\u63D2\u4EF6\u540E\u91CD\u542F dsh web" });
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0 });
|
|
1380
|
+
try {
|
|
1381
|
+
const data = this.unwrap(await api.listVersions({ id }));
|
|
1382
|
+
this.onChange({ versionsBusy: "", versions: data });
|
|
1383
|
+
await this.readVersion((data?.versions?.length ?? 0) - 1);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
this.onChange({ versionsBusy: "", versions: void 0, versionsError: String(error && error.message || error) });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/** Load one version's diff; the modal swaps its right pane. */
|
|
1389
|
+
async readVersion(index) {
|
|
1390
|
+
const api = this.api();
|
|
1391
|
+
const versions = this.state.versions;
|
|
1392
|
+
if (api === void 0 || versions === void 0 || index < 0) return;
|
|
1393
|
+
this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
|
|
1394
|
+
try {
|
|
1395
|
+
const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
|
|
1396
|
+
this.onChange({ versionsBusy: "", versionDiff: diff });
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
this.onChange({ versionsBusy: "", versionDiff: void 0, versionsError: String(error && error.message || error) });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
/** Put one version back as the persona text (itself recorded as a new version). */
|
|
1402
|
+
async restoreVersion(index) {
|
|
1403
|
+
const api = this.api();
|
|
1404
|
+
const versions = this.state.versions;
|
|
1405
|
+
if (api === void 0 || versions === void 0) return;
|
|
1406
|
+
this.onChange({ versionsBusy: "restore", versionsError: "" });
|
|
1407
|
+
try {
|
|
1408
|
+
const view = this.unwrap(await api.restoreVersion({ id: versions.id, index }));
|
|
1409
|
+
const found = (view?.personas ?? []).find((item) => item.id === versions.id);
|
|
1410
|
+
this.onChange({
|
|
1411
|
+
view,
|
|
1412
|
+
versionsBusy: "",
|
|
1413
|
+
status: view?.notice ?? "\u5DF2\u6062\u590D",
|
|
1414
|
+
draft: this.state.openId === versions.id && found !== void 0 ? this.draftFrom(found) : this.state.draft
|
|
1415
|
+
});
|
|
1416
|
+
await this.openVersions(versions.id);
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1326
1421
|
/** Read a session's recent messages so the user can recognise it. */
|
|
1327
1422
|
async openHistory(sessionId) {
|
|
1328
1423
|
const api = this.api();
|
|
@@ -2267,6 +2362,14 @@ ${state.proposal.trim()}
|
|
|
2267
2362
|
h("div", { className: "wsp-foot", key: "foot" }, [
|
|
2268
2363
|
h("span", { className: "wsp-caption", key: "c" }, `${draft2.text.length} \u5B57 \xB7 \u2318/Ctrl+S \u4FDD\u5B58`),
|
|
2269
2364
|
h("div", { className: "wsp-actions", key: "a" }, [
|
|
2365
|
+
this.state.prefs?.history === true ? h(Button, {
|
|
2366
|
+
key: "ver",
|
|
2367
|
+
variant: "ghost",
|
|
2368
|
+
size: "sm",
|
|
2369
|
+
disabled: busy,
|
|
2370
|
+
title: "\u67E5\u770B\u8FD9\u6761\u4EBA\u8BBE\u7684\u7248\u672C\u5386\u53F2",
|
|
2371
|
+
onClick: () => void this.openVersions(draft2.id)
|
|
2372
|
+
}, "\u7248\u672C\u5386\u53F2") : null,
|
|
2270
2373
|
h(Button, { key: "dup", variant: "ghost", size: "sm", disabled: busy, onClick: () => void this.duplicate(draft2.id) }, "\u590D\u5236"),
|
|
2271
2374
|
state.confirmDelete === draft2.id ? h(Button, { key: "del2", variant: "ghost", size: "sm", className: "wsp-icon-btn-danger", disabled: busy, onClick: () => void this.remove(draft2.id) }, "\u786E\u8BA4\u5220\u9664") : h(Button, {
|
|
2272
2375
|
key: "del",
|
|
@@ -2391,7 +2494,10 @@ ${state.proposal.trim()}
|
|
|
2391
2494
|
}
|
|
2392
2495
|
}, [h(IconPlusOutline16, { key: "i" }), h("span", { key: "t" }, busy ? "\u6B63\u5728\u65B0\u5EFA\u2026" : "\u65B0\u5EFA\u4EBA\u8BBE")])];
|
|
2393
2496
|
const status = state.error !== "" ? h("div", { className: "wsp-status wsp-status-err", key: "st", "aria-live": "polite" }, state.error) : state.status === "" ? null : h(Toast, {
|
|
2394
|
-
|
|
2497
|
+
// Keyed on the sequence, not on a constant: a repeated sentence has to mount a fresh
|
|
2498
|
+
// toast. With a fixed key React reuses the instance, its dismiss timer keeps running,
|
|
2499
|
+
// and the second flip of a switch produced no toast at all.
|
|
2500
|
+
key: `st-${String(state.statusSeq)}`,
|
|
2395
2501
|
text: state.status,
|
|
2396
2502
|
icon: h(IconCheckOutline14, {}),
|
|
2397
2503
|
onDone: () => this.onChange({ status: "" })
|
|
@@ -2501,6 +2607,13 @@ ${state.proposal.trim()}
|
|
|
2501
2607
|
label: "\u7981\u6B62\u6A21\u578B\u6539\u5199\u4EBA\u8BBE\u6587\u4EF6",
|
|
2502
2608
|
hint: "\u6253\u5F00\u540E\uFF0C\u6A21\u578B\u7684\u6587\u4EF6\u4E0E\u547D\u4EE4\u5DE5\u5177\u4E0D\u80FD\u5199\u5165\u6216\u5220\u9664\u4EBA\u8BBE\u5B58\u50A8\uFF08personas.json \u53CA\u5176\u5907\u4EFD\uFF09\uFF1B\u8BFB\u53D6\u4E0D\u53D7\u5F71\u54CD\u3002\u5173\u6389\u540E\u6A21\u578B\u5C31\u80FD\u76F4\u63A5\u6539\u5B83\u3002\u8FD9\u662F\u884C\u4E3A\u5C42\u9762\u7684\u9650\u5236\uFF0C\u4E0D\u662F\u5B89\u5168\u8FB9\u754C\uFF1A\u672C\u673A\u4E0A\u7684\u5176\u5B83\u7A0B\u5E8F\u4ECD\u53EF\u4FEE\u6539\u3002",
|
|
2503
2609
|
onChange: (next) => this.setPref({ guard: next })
|
|
2610
|
+
}),
|
|
2611
|
+
h(Switch, {
|
|
2612
|
+
key: "history",
|
|
2613
|
+
checked: prefs.history === true,
|
|
2614
|
+
label: "\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2",
|
|
2615
|
+
hint: "\u6253\u5F00\u540E\u6BCF\u6B21\u4FDD\u5B58\u90FD\u4F1A\u628A\u8FD9\u6B21\u7684\u6B63\u6587\u6539\u52A8\u8BB0\u6210\u4E00\u6761 diff\uFF08\u53E6\u5B58 personas.history.json\uFF09\uFF0C\u53EF\u4EE5\u5728\u7F16\u8F91\u5668\u7684\u300C\u7248\u672C\u5386\u53F2\u300D\u91CC\u5BF9\u6BD4\uFF0C\u5E76\u628A\u4EFB\u610F\u4E00\u6761\u6062\u590D\u6210\u5F53\u524D\u6B63\u6587\u3002\u53EA\u8BB0\u6B63\u6587\uFF0C\u4E0D\u8BB0\u540D\u79F0\u4E0E\u8303\u56F4\u3002",
|
|
2616
|
+
onChange: (next) => this.setPref({ history: next })
|
|
2504
2617
|
})
|
|
2505
2618
|
]);
|
|
2506
2619
|
const pendingDelete = state.confirmDelete === void 0 || state.confirmDelete === null ? void 0 : personas.find((item) => item.id === state.confirmDelete);
|
|
@@ -2515,6 +2628,69 @@ ${state.proposal.trim()}
|
|
|
2515
2628
|
h(Button, { key: "ok", variant: "primary", size: "sm", disabled: busy, onClick: () => void this.remove(pendingDelete.id) }, "\u5220\u9664")
|
|
2516
2629
|
])
|
|
2517
2630
|
}, h("div", { className: "wsp-card-note" }, `\u540D\u79F0\uFF1A${pendingDelete.name}\u3000\xB7\u3000${pendingDelete.chars} \u5B57\u3000\xB7\u3000${pendingDelete.targets?.length ?? 0} \u6761\u9002\u7528\u8303\u56F4`));
|
|
2631
|
+
const versionsState = state.versions;
|
|
2632
|
+
const diffState = state.versionDiff;
|
|
2633
|
+
const versionRows = versionsState === void 0 ? [] : [...versionsState.versions ?? []].reverse();
|
|
2634
|
+
const diffLines = [];
|
|
2635
|
+
if (diffState !== void 0) {
|
|
2636
|
+
for (const op of diffState.ops ?? []) {
|
|
2637
|
+
if (op.t === "=") {
|
|
2638
|
+
diffLines.push(h("div", { className: "wsp-diff-skip", key: `s${diffLines.length}` }, `\u22EF ${op.n} \u884C\u672A\u6539\u52A8`));
|
|
2639
|
+
continue;
|
|
2640
|
+
}
|
|
2641
|
+
for (const line of op.lines ?? []) {
|
|
2642
|
+
diffLines.push(h("div", {
|
|
2643
|
+
className: `wsp-diff-line ${op.t === "+" ? "wsp-diff-add" : "wsp-diff-del"}`,
|
|
2644
|
+
key: `d${diffLines.length}`
|
|
2645
|
+
}, `${op.t === "+" ? "+" : "\u2212"} ${line === "" ? " " : line}`));
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
const versionsModal = versionsState === void 0 && state.versionsError === "" ? null : h(Modal, {
|
|
2650
|
+
key: "versions",
|
|
2651
|
+
open: true,
|
|
2652
|
+
onClose: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 }),
|
|
2653
|
+
title: `\u7248\u672C\u5386\u53F2${versionsState?.name === void 0 ? "" : ` \xB7 ${versionsState.name}`}`,
|
|
2654
|
+
description: "\u6BCF\u6B21\u4FDD\u5B58\u8BB0\u4E00\u6761\u6B63\u6587 diff\u3002\u6062\u590D\u4F1A\u628A\u9009\u4E2D\u7684\u7248\u672C\u5199\u56DE\u5F53\u524D\u4EBA\u8BBE\uFF0C\u5E76\u4F5C\u4E3A\u4E00\u6761\u65B0\u8BB0\u5F55\uFF0C\u6240\u4EE5\u6062\u590D\u672C\u8EAB\u4E5F\u80FD\u518D\u56DE\u6EDA\u3002",
|
|
2655
|
+
footer: h("div", { className: "wsp-form-actions" }, [
|
|
2656
|
+
h(Button, {
|
|
2657
|
+
key: "close",
|
|
2658
|
+
variant: "outline",
|
|
2659
|
+
size: "sm",
|
|
2660
|
+
onClick: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 })
|
|
2661
|
+
}, "\u5173\u95ED"),
|
|
2662
|
+
h(Button, {
|
|
2663
|
+
key: "restore",
|
|
2664
|
+
variant: "primary",
|
|
2665
|
+
size: "sm",
|
|
2666
|
+
disabled: state.versionsBusy !== "" || state.versionIndex === void 0 || diffState === void 0,
|
|
2667
|
+
onClick: () => void this.restoreVersion(state.versionIndex)
|
|
2668
|
+
}, state.versionsBusy === "restore" ? "\u6062\u590D\u4E2D\u2026" : "\u6062\u590D\u5230\u6B64\u7248\u672C")
|
|
2669
|
+
])
|
|
2670
|
+
}, h("div", { className: "wsp-versions" }, [
|
|
2671
|
+
h("div", { className: "wsp-versions-list", key: "l" }, versionRows.length === 0 ? h("div", { className: "wsp-card-note" }, state.versionsError !== "" ? state.versionsError : "\u8FD8\u6CA1\u6709\u5386\u53F2\u7248\u672C\uFF1A\u5148\u5728\u5F00\u5173\u91CC\u6253\u5F00\u300C\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2\u300D\uFF0C\u7136\u540E\u4FDD\u5B58\u4E00\u6B21\u6B63\u6587\u3002") : versionRows.map((version) => h("button", {
|
|
2672
|
+
type: "button",
|
|
2673
|
+
key: version.index,
|
|
2674
|
+
className: `wsp-version-row${state.versionIndex === version.index ? " is-active" : ""}`,
|
|
2675
|
+
onClick: () => void this.readVersion(version.index)
|
|
2676
|
+
}, [
|
|
2677
|
+
h("span", { className: "wsp-version-title", key: "t" }, `\u7B2C ${version.index + 1} \u7248`),
|
|
2678
|
+
h(
|
|
2679
|
+
"span",
|
|
2680
|
+
{ className: "wsp-version-meta", key: "m" },
|
|
2681
|
+
`${String(version.at).replace("T", " ").slice(5, 19)}\u3000+${version.added} / \u2212${version.removed}`
|
|
2682
|
+
),
|
|
2683
|
+
h("span", { className: "wsp-version-preview", key: "p" }, version.preview || "\uFF08\u65E0\u53EF\u663E\u793A\u7684\u6539\u52A8\u884C\uFF09")
|
|
2684
|
+
]))),
|
|
2685
|
+
h("div", { className: "wsp-versions-diff", key: "d" }, diffState === void 0 ? h("div", { className: "wsp-card-note" }, "\u5DE6\u4FA7\u9009\u4E00\u4E2A\u7248\u672C\uFF0C\u8FD9\u91CC\u663E\u793A\u5B83\u76F8\u5BF9\u4E0A\u4E00\u7248\u7684\u6539\u52A8\u3002") : [
|
|
2686
|
+
h(
|
|
2687
|
+
"div",
|
|
2688
|
+
{ className: "wsp-diff-head", key: "h" },
|
|
2689
|
+
`${String(diffState.at).replace("T", " ").slice(0, 19)}\u3000\xB7\u3000${diffState.action === "restore" ? "\u6062\u590D" : diffState.action === "create" ? "\u65B0\u5EFA" : "\u4FEE\u6539"}\u3000\xB7\u3000+${diffState.added} / \u2212${diffState.removed}`
|
|
2690
|
+
),
|
|
2691
|
+
h("div", { className: "wsp-diff-body", key: "b" }, diffLines)
|
|
2692
|
+
])
|
|
2693
|
+
]));
|
|
2518
2694
|
const health = view?.storeHealth;
|
|
2519
2695
|
const healthNote = health === void 0 || health.status === "ok" ? null : h("div", {
|
|
2520
2696
|
className: `wsp-alert wsp-alert-${health.status}`,
|
|
@@ -2621,7 +2797,7 @@ ${state.proposal.trim()}
|
|
|
2621
2797
|
])
|
|
2622
2798
|
])
|
|
2623
2799
|
]));
|
|
2624
|
-
return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, importModal]);
|
|
2800
|
+
return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, versionsModal, importModal]);
|
|
2625
2801
|
}
|
|
2626
2802
|
}
|
|
2627
2803
|
const inject = ["slots", "remote", "connection"];
|
package/lib/endpoints.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export declare const RPC_PACKAGE = "dsh-agent-persona";
|
|
|
11
11
|
*/
|
|
12
12
|
export declare const RPC_SERVICE = "agentPersona";
|
|
13
13
|
export declare const RPC_CHANNEL = "/agent-persona";
|
|
14
|
-
export declare const RPC_ENDPOINTS: readonly ['listPersonas', 'savePersona', 'deletePersona', 'movePersona', 'duplicatePersona', 'reorderPersona', 'listTargets', 'sessionHistory', 'sessionPrompt', 'tunePersona', 'listModels', 'savePrefs'];
|
|
14
|
+
export declare const RPC_ENDPOINTS: readonly ['listPersonas', 'savePersona', 'deletePersona', 'movePersona', 'duplicatePersona', 'reorderPersona', 'listTargets', 'sessionHistory', 'sessionPrompt', 'tunePersona', 'listModels', 'savePrefs', 'listVersions', 'versionDiff', 'restoreVersion'];
|
|
15
15
|
/** The three reads that take no argument. Everything else receives one JSON object. */
|
|
16
16
|
export declare const RPC_NO_INPUT: readonly ['listPersonas', 'listTargets', 'listModels'];
|
package/lib/endpoints.js
CHANGED
|
@@ -24,6 +24,9 @@ export const RPC_ENDPOINTS = [
|
|
|
24
24
|
'tunePersona',
|
|
25
25
|
'listModels',
|
|
26
26
|
'savePrefs',
|
|
27
|
+
'listVersions',
|
|
28
|
+
'versionDiff',
|
|
29
|
+
'restoreVersion',
|
|
27
30
|
];
|
|
28
31
|
/** The three reads that take no argument. Everything else receives one JSON object. */
|
|
29
32
|
export const RPC_NO_INPUT = ['listPersonas', 'listTargets', 'listModels'];
|
package/lib/index.d.ts
CHANGED
|
@@ -89,6 +89,8 @@ export declare const normalizePrefs: (raw: any) => {
|
|
|
89
89
|
showTab: boolean;
|
|
90
90
|
showSidebar: boolean;
|
|
91
91
|
guard: boolean;
|
|
92
|
+
/** Opt-in version history: off by default, because it writes a second file on every save. */
|
|
93
|
+
history: boolean;
|
|
92
94
|
};
|
|
93
95
|
/** Filename-safe local timestamp for a store backup. */
|
|
94
96
|
export declare const backupStamp: (now?: Date) => string;
|
|
@@ -108,6 +110,45 @@ export declare const backupStoreFile: (storePath: any, stamp: any) => string;
|
|
|
108
110
|
*/
|
|
109
111
|
export declare const personaStoreGuardReason: (execution: any, storePath: any) => string;
|
|
110
112
|
export declare const sanitizePersona: (text: any, warn?: () => void) => string;
|
|
113
|
+
/**
|
|
114
|
+
* Opt-in per-persona version history.
|
|
115
|
+
*
|
|
116
|
+
* Every save stores the diff against the previous text (equal runs as counts, changed lines verbatim),
|
|
117
|
+
* so one record costs about as much as the edit rather than the whole document. The first version of a
|
|
118
|
+
* persona is kept as a base snapshot; every later version is reconstructed by replaying the diffs from
|
|
119
|
+
* that base. A hash per entry makes a corrupted or hand-edited history fail loudly instead of restoring
|
|
120
|
+
* the wrong text.
|
|
121
|
+
*/
|
|
122
|
+
export declare const HISTORY_VERSION = 1;
|
|
123
|
+
/** Versions kept per persona; the oldest are folded into the base snapshot when the cap is hit. */
|
|
124
|
+
export declare const MAX_VERSIONS = 200;
|
|
125
|
+
export declare const historyPathFor: (storePath: any) => any;
|
|
126
|
+
/**
|
|
127
|
+
* Line diff between two texts, as a sequence of equal/removed/added runs.
|
|
128
|
+
*
|
|
129
|
+
* Longest-common-subsequence over lines, with the shared prefix and suffix stripped first. That keeps the
|
|
130
|
+
* usual "edit one paragraph" case cheap; a pathological rewrite falls back to one remove+add pair, which
|
|
131
|
+
* is still a correct (if coarse) diff.
|
|
132
|
+
*/
|
|
133
|
+
export declare const diffLines: (beforeText: any, afterText: any) => any[];
|
|
134
|
+
/**
|
|
135
|
+
* Replay a diff onto a text. Equal runs count lines from the current text (their content is not stored, which
|
|
136
|
+
* is what keeps a record small); removals are checked against it so a mismatched history throws instead of
|
|
137
|
+
* silently producing a different document.
|
|
138
|
+
*/
|
|
139
|
+
export declare const applyDiff: (text: any, ops: any) => string;
|
|
140
|
+
/** `+n / -m` summary used by the list row. */
|
|
141
|
+
export declare const diffStats: (ops: any) => {
|
|
142
|
+
added: number;
|
|
143
|
+
removed: number;
|
|
144
|
+
};
|
|
145
|
+
/** Current text of one stored version (index 0 is the oldest kept version). */
|
|
146
|
+
export declare const versionTextAt: (entryState: any, index: any) => any;
|
|
147
|
+
/**
|
|
148
|
+
* Record one new version. `before` is the text the persona had, `after` the text it now has.
|
|
149
|
+
* Returns false when the history is off or nothing changed.
|
|
150
|
+
*/
|
|
151
|
+
export declare const recordVersion: (history: any, personaId: any, before: any, after: any, action: any, now?: string) => boolean;
|
|
111
152
|
/**
|
|
112
153
|
* Does one target claim this subject?
|
|
113
154
|
* @param target - normalized target record.
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
|
50
50
|
import Schema from '@deepseek-ai/schemastery';
|
|
51
51
|
import { RPC_ENDPOINTS } from './endpoints.js';
|
|
52
52
|
import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { createHash } from 'node:crypto';
|
|
53
54
|
import { readFile } from 'node:fs/promises';
|
|
54
55
|
import { homedir } from 'node:os';
|
|
55
56
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
@@ -171,6 +172,8 @@ export const normalizePrefs = (raw) => ({
|
|
|
171
172
|
showTab: raw?.showTab !== false,
|
|
172
173
|
showSidebar: raw?.showSidebar !== false,
|
|
173
174
|
guard: raw?.guard !== false,
|
|
175
|
+
/** Opt-in version history: off by default, because it writes a second file on every save. */
|
|
176
|
+
history: raw?.history === true,
|
|
174
177
|
});
|
|
175
178
|
/**
|
|
176
179
|
* Read the store. A missing file is a normal first run and stays silent; a file that exists but cannot be
|
|
@@ -300,6 +303,236 @@ export const sanitizePersona = (text, warn = () => { }) => {
|
|
|
300
303
|
warn(`persona text references unsupported prompt variables (${[...new Set(dropped)].join(', ')}); rendered as plain text`);
|
|
301
304
|
return out;
|
|
302
305
|
};
|
|
306
|
+
// ─────────────────────────────────────────────────────────── version history
|
|
307
|
+
/**
|
|
308
|
+
* Opt-in per-persona version history.
|
|
309
|
+
*
|
|
310
|
+
* Every save stores the diff against the previous text (equal runs as counts, changed lines verbatim),
|
|
311
|
+
* so one record costs about as much as the edit rather than the whole document. The first version of a
|
|
312
|
+
* persona is kept as a base snapshot; every later version is reconstructed by replaying the diffs from
|
|
313
|
+
* that base. A hash per entry makes a corrupted or hand-edited history fail loudly instead of restoring
|
|
314
|
+
* the wrong text.
|
|
315
|
+
*/
|
|
316
|
+
export const HISTORY_VERSION = 1;
|
|
317
|
+
/** Versions kept per persona; the oldest are folded into the base snapshot when the cap is hit. */
|
|
318
|
+
export const MAX_VERSIONS = 200;
|
|
319
|
+
export const historyPathFor = (storePath) => join(dirname(storePath), 'personas.history.json');
|
|
320
|
+
const splitLines = (text) => String(text ?? '').split('\n');
|
|
321
|
+
/**
|
|
322
|
+
* Line diff between two texts, as a sequence of equal/removed/added runs.
|
|
323
|
+
*
|
|
324
|
+
* Longest-common-subsequence over lines, with the shared prefix and suffix stripped first. That keeps the
|
|
325
|
+
* usual "edit one paragraph" case cheap; a pathological rewrite falls back to one remove+add pair, which
|
|
326
|
+
* is still a correct (if coarse) diff.
|
|
327
|
+
*/
|
|
328
|
+
export const diffLines = (beforeText, afterText) => {
|
|
329
|
+
const a = splitLines(beforeText);
|
|
330
|
+
const b = splitLines(afterText);
|
|
331
|
+
let head = 0;
|
|
332
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
333
|
+
head += 1;
|
|
334
|
+
let tail = 0;
|
|
335
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
336
|
+
tail += 1;
|
|
337
|
+
const midA = a.slice(head, a.length - tail);
|
|
338
|
+
const midB = b.slice(head, b.length - tail);
|
|
339
|
+
const ops = [];
|
|
340
|
+
const pushEqual = (n) => {
|
|
341
|
+
if (n <= 0)
|
|
342
|
+
return;
|
|
343
|
+
const last = ops[ops.length - 1];
|
|
344
|
+
if (last?.t === '=')
|
|
345
|
+
last.n += n;
|
|
346
|
+
else
|
|
347
|
+
ops.push({ t: '=', n });
|
|
348
|
+
};
|
|
349
|
+
pushEqual(head);
|
|
350
|
+
const n = midA.length;
|
|
351
|
+
const m = midB.length;
|
|
352
|
+
if (n > 0 && m > 0 && n * m <= 4_000_000) {
|
|
353
|
+
// DP table over the changed middle only.
|
|
354
|
+
const width = m + 1;
|
|
355
|
+
const table = new Uint32Array((n + 1) * width);
|
|
356
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
357
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
358
|
+
table[i * width + j] = midA[i] === midB[j]
|
|
359
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
360
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
let i = 0;
|
|
364
|
+
let j = 0;
|
|
365
|
+
let equal = 0;
|
|
366
|
+
const flushEqual = () => {
|
|
367
|
+
pushEqual(equal);
|
|
368
|
+
equal = 0;
|
|
369
|
+
};
|
|
370
|
+
while (i < n && j < m) {
|
|
371
|
+
if (midA[i] === midB[j]) {
|
|
372
|
+
equal += 1;
|
|
373
|
+
i += 1;
|
|
374
|
+
j += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
flushEqual();
|
|
378
|
+
if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
379
|
+
const last = ops[ops.length - 1];
|
|
380
|
+
if (last?.t === '-')
|
|
381
|
+
last.lines.push(midA[i]);
|
|
382
|
+
else
|
|
383
|
+
ops.push({ t: '-', lines: [midA[i]] });
|
|
384
|
+
i += 1;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const last = ops[ops.length - 1];
|
|
388
|
+
if (last?.t === '+')
|
|
389
|
+
last.lines.push(midB[j]);
|
|
390
|
+
else
|
|
391
|
+
ops.push({ t: '+', lines: [midB[j]] });
|
|
392
|
+
j += 1;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
flushEqual();
|
|
396
|
+
if (i < n)
|
|
397
|
+
ops.push({ t: '-', lines: midA.slice(i) });
|
|
398
|
+
if (j < m)
|
|
399
|
+
ops.push({ t: '+', lines: midB.slice(j) });
|
|
400
|
+
}
|
|
401
|
+
else if (n > 0 || m > 0) {
|
|
402
|
+
if (n > 0)
|
|
403
|
+
ops.push({ t: '-', lines: midA.slice() });
|
|
404
|
+
if (m > 0)
|
|
405
|
+
ops.push({ t: '+', lines: midB.slice() });
|
|
406
|
+
}
|
|
407
|
+
pushEqual(tail);
|
|
408
|
+
return ops;
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Replay a diff onto a text. Equal runs count lines from the current text (their content is not stored, which
|
|
412
|
+
* is what keeps a record small); removals are checked against it so a mismatched history throws instead of
|
|
413
|
+
* silently producing a different document.
|
|
414
|
+
*/
|
|
415
|
+
export const applyDiff = (text, ops) => {
|
|
416
|
+
const lines = splitLines(text);
|
|
417
|
+
const out = [];
|
|
418
|
+
let at = 0;
|
|
419
|
+
for (const op of Array.isArray(ops) ? ops : []) {
|
|
420
|
+
if (op?.t === '=') {
|
|
421
|
+
const n = Math.max(0, Number(op.n) || 0);
|
|
422
|
+
for (let k = 0; k < n; k += 1) {
|
|
423
|
+
if (at >= lines.length)
|
|
424
|
+
throw new Error('版本历史损坏:相等的行超出当前文本');
|
|
425
|
+
out.push(lines[at]);
|
|
426
|
+
at += 1;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (op?.t === '-') {
|
|
430
|
+
const gone = Array.isArray(op.lines) ? op.lines : [];
|
|
431
|
+
for (const line of gone) {
|
|
432
|
+
if (lines[at] !== line)
|
|
433
|
+
throw new Error('版本历史损坏:删除的行与当前文本不一致');
|
|
434
|
+
at += 1;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (op?.t === '+') {
|
|
438
|
+
for (const line of Array.isArray(op.lines) ? op.lines : [])
|
|
439
|
+
out.push(line);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
while (at < lines.length) {
|
|
443
|
+
out.push(lines[at]);
|
|
444
|
+
at += 1;
|
|
445
|
+
}
|
|
446
|
+
return out.join('\n');
|
|
447
|
+
};
|
|
448
|
+
/** `+n / -m` summary used by the list row. */
|
|
449
|
+
export const diffStats = (ops) => {
|
|
450
|
+
let added = 0;
|
|
451
|
+
let removed = 0;
|
|
452
|
+
for (const op of Array.isArray(ops) ? ops : []) {
|
|
453
|
+
if (op?.t === '+')
|
|
454
|
+
added += op.lines.length;
|
|
455
|
+
if (op?.t === '-')
|
|
456
|
+
removed += op.lines.length;
|
|
457
|
+
}
|
|
458
|
+
return { added, removed };
|
|
459
|
+
};
|
|
460
|
+
const readHistory = (storePath, warn) => {
|
|
461
|
+
const file = historyPathFor(storePath);
|
|
462
|
+
try {
|
|
463
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
464
|
+
if (parsed && typeof parsed === 'object' && parsed.personas && typeof parsed.personas === 'object') {
|
|
465
|
+
return { version: HISTORY_VERSION, personas: parsed.personas };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (error?.code !== 'ENOENT')
|
|
470
|
+
warn(`could not read the version history; starting a fresh one`, error);
|
|
471
|
+
}
|
|
472
|
+
return { version: HISTORY_VERSION, personas: {} };
|
|
473
|
+
};
|
|
474
|
+
const persistFile = (file, text, mode) => {
|
|
475
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
476
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
477
|
+
writeFileSync(tmp, text, { mode });
|
|
478
|
+
renameSync(tmp, file);
|
|
479
|
+
};
|
|
480
|
+
const writeHistory = (storePath, history, warn) => {
|
|
481
|
+
const file = historyPathFor(storePath);
|
|
482
|
+
try {
|
|
483
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
484
|
+
persistFile(file, `${JSON.stringify(history)}\n`, 0o600);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
warn(`could not write the version history`, error);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
/** Current text of one stored version (index 0 is the oldest kept version). */
|
|
491
|
+
export const versionTextAt = (entryState, index) => {
|
|
492
|
+
const state = entryState;
|
|
493
|
+
if (!state || typeof state.base !== 'string')
|
|
494
|
+
throw new Error('这条人设还没有版本历史');
|
|
495
|
+
const versions = Array.isArray(state.versions) ? state.versions : [];
|
|
496
|
+
if (!Number.isInteger(index) || index < 0 || index >= versions.length)
|
|
497
|
+
throw new Error('版本不存在');
|
|
498
|
+
let text = state.base;
|
|
499
|
+
for (let k = 0; k <= index; k += 1)
|
|
500
|
+
text = applyDiff(text, versions[k].ops);
|
|
501
|
+
return text;
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* Record one new version. `before` is the text the persona had, `after` the text it now has.
|
|
505
|
+
* Returns false when the history is off or nothing changed.
|
|
506
|
+
*/
|
|
507
|
+
export const recordVersion = (history, personaId, before, after, action, now = new Date().toISOString()) => {
|
|
508
|
+
const beforeText = String(before ?? '');
|
|
509
|
+
const afterText = String(after ?? '');
|
|
510
|
+
if (beforeText === afterText)
|
|
511
|
+
return false;
|
|
512
|
+
const ops = diffLines(beforeText, afterText);
|
|
513
|
+
const stats = diffStats(ops);
|
|
514
|
+
let state = history.personas[personaId];
|
|
515
|
+
if (state === undefined || typeof state.base !== 'string') {
|
|
516
|
+
state = { base: beforeText, baseAt: now, versions: [] };
|
|
517
|
+
history.personas[personaId] = state;
|
|
518
|
+
}
|
|
519
|
+
state.versions.push({
|
|
520
|
+
at: now,
|
|
521
|
+
action,
|
|
522
|
+
ops,
|
|
523
|
+
added: stats.added,
|
|
524
|
+
removed: stats.removed,
|
|
525
|
+
hash: createHash('sha256').update(afterText, 'utf8').digest('hex').slice(0, 16),
|
|
526
|
+
size: Buffer.byteLength(afterText, 'utf8'),
|
|
527
|
+
});
|
|
528
|
+
// Fold the oldest versions into the base once the cap is hit, so every kept version stays replayable.
|
|
529
|
+
while (state.versions.length > MAX_VERSIONS) {
|
|
530
|
+
const [oldest] = state.versions.splice(0, 1);
|
|
531
|
+
state.base = applyDiff(state.base, oldest.ops);
|
|
532
|
+
state.baseAt = oldest.at;
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
};
|
|
303
536
|
// ─────────────────────────────────────────────────────────── matching
|
|
304
537
|
const regexCache = new Map();
|
|
305
538
|
const compile = (value) => {
|
|
@@ -1073,6 +1306,33 @@ class WorkspacePersonaService extends TypertRemoteService {
|
|
|
1073
1306
|
return { error: `savePersona failed: ${String((error && error.stack) || error)}` };
|
|
1074
1307
|
}
|
|
1075
1308
|
}
|
|
1309
|
+
/** Version list for one persona (opt-in history). */
|
|
1310
|
+
async listVersions(input) {
|
|
1311
|
+
try {
|
|
1312
|
+
return this.runtime.listVersions(input);
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
return { error: `listVersions failed: ${String((error && error.stack) || error)}` };
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
/** One stored version with its diff. */
|
|
1319
|
+
async versionDiff(input) {
|
|
1320
|
+
try {
|
|
1321
|
+
return this.runtime.versionDiff(input);
|
|
1322
|
+
}
|
|
1323
|
+
catch (error) {
|
|
1324
|
+
return { error: `versionDiff failed: ${String((error && error.stack) || error)}` };
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/** Restore a stored version as the current persona text. */
|
|
1328
|
+
async restoreVersion(input) {
|
|
1329
|
+
try {
|
|
1330
|
+
return this.runtime.restoreVersion(input);
|
|
1331
|
+
}
|
|
1332
|
+
catch (error) {
|
|
1333
|
+
return { error: `restoreVersion failed: ${String((error && error.stack) || error)}` };
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1076
1336
|
/** Remove one persona. */
|
|
1077
1337
|
async deletePersona(input) {
|
|
1078
1338
|
try {
|
|
@@ -1401,8 +1661,90 @@ export function apply(ctx, config = {}) {
|
|
|
1401
1661
|
targets,
|
|
1402
1662
|
};
|
|
1403
1663
|
};
|
|
1664
|
+
/** Read + write the history file around one mutation; a disabled switch costs nothing. */
|
|
1665
|
+
const historyEnabled = () => currentStore().prefs?.history === true;
|
|
1666
|
+
const recordIfEnabled = (personaId, before, after, action) => {
|
|
1667
|
+
if (!historyEnabled() || personaId === undefined)
|
|
1668
|
+
return;
|
|
1669
|
+
const history = readHistory(storePath, warn);
|
|
1670
|
+
if (!recordVersion(history, personaId, before, after, action))
|
|
1671
|
+
return;
|
|
1672
|
+
writeHistory(storePath, history, warn);
|
|
1673
|
+
};
|
|
1404
1674
|
const runtime = {
|
|
1405
1675
|
view,
|
|
1676
|
+
/** Version list for one persona (newest first for display, indices stay oldest-first). */
|
|
1677
|
+
listVersions(input) {
|
|
1678
|
+
const id = typeof input?.id === 'string' ? input.id : '';
|
|
1679
|
+
const persona = currentStore().personas.find((item) => item.id === id);
|
|
1680
|
+
if (persona === undefined)
|
|
1681
|
+
throw new Error('人设不存在(可能已被删除)');
|
|
1682
|
+
const history = readHistory(storePath, warn);
|
|
1683
|
+
const state = history.personas[id];
|
|
1684
|
+
const versions = Array.isArray(state?.versions) ? state.versions : [];
|
|
1685
|
+
return {
|
|
1686
|
+
id,
|
|
1687
|
+
name: persona.name,
|
|
1688
|
+
enabled: historyEnabled(),
|
|
1689
|
+
baseAt: state?.baseAt ?? '',
|
|
1690
|
+
max: MAX_VERSIONS,
|
|
1691
|
+
current: persona.text,
|
|
1692
|
+
versions: versions.map((version, index) => ({
|
|
1693
|
+
index,
|
|
1694
|
+
at: version.at,
|
|
1695
|
+
action: version.action,
|
|
1696
|
+
added: version.added,
|
|
1697
|
+
removed: version.removed,
|
|
1698
|
+
size: version.size,
|
|
1699
|
+
preview: (version.ops ?? []).find((op) => op.t === '+')?.lines?.[0] ?? '',
|
|
1700
|
+
})),
|
|
1701
|
+
};
|
|
1702
|
+
},
|
|
1703
|
+
/** One version with the text it produced and the diff against the version before it. */
|
|
1704
|
+
versionDiff(input) {
|
|
1705
|
+
const id = typeof input?.id === 'string' ? input.id : '';
|
|
1706
|
+
const persona = currentStore().personas.find((item) => item.id === id);
|
|
1707
|
+
if (persona === undefined)
|
|
1708
|
+
throw new Error('人设不存在(可能已被删除)');
|
|
1709
|
+
const history = readHistory(storePath, warn);
|
|
1710
|
+
const state = history.personas[id];
|
|
1711
|
+
const index = Number(input?.index);
|
|
1712
|
+
const version = state?.versions?.[index];
|
|
1713
|
+
if (version === undefined)
|
|
1714
|
+
throw new Error('版本不存在');
|
|
1715
|
+
const after = versionTextAt(state, index);
|
|
1716
|
+
const before = index === 0 ? state.base : versionTextAt(state, index - 1);
|
|
1717
|
+
return {
|
|
1718
|
+
id,
|
|
1719
|
+
index,
|
|
1720
|
+
at: version.at,
|
|
1721
|
+
action: version.action,
|
|
1722
|
+
added: version.added,
|
|
1723
|
+
removed: version.removed,
|
|
1724
|
+
ops: version.ops,
|
|
1725
|
+
before,
|
|
1726
|
+
after,
|
|
1727
|
+
current: persona.text,
|
|
1728
|
+
};
|
|
1729
|
+
},
|
|
1730
|
+
/** Put a stored version back as the persona text; the restore itself is recorded as a new version. */
|
|
1731
|
+
restoreVersion(input) {
|
|
1732
|
+
const current = reload();
|
|
1733
|
+
const id = typeof input?.id === 'string' ? input.id : '';
|
|
1734
|
+
const persona = current.personas.find((item) => item.id === id);
|
|
1735
|
+
if (persona === undefined)
|
|
1736
|
+
throw new Error('人设不存在(可能已被删除)');
|
|
1737
|
+
const history = readHistory(storePath, warn);
|
|
1738
|
+
const text = versionTextAt(history.personas[id], Number(input?.index));
|
|
1739
|
+
const before = persona.text;
|
|
1740
|
+
persona.text = text;
|
|
1741
|
+
commit(current);
|
|
1742
|
+
recordIfEnabled(id, before, text, 'restore');
|
|
1743
|
+
info(`restored persona "${persona.name}" to version ${String(input?.index)}`);
|
|
1744
|
+
const result = view();
|
|
1745
|
+
result.notice = `已恢复到第 ${Number(input?.index) + 1} 个版本`;
|
|
1746
|
+
return result;
|
|
1747
|
+
},
|
|
1406
1748
|
/** Merge preferences into the store; every field is optional so one switch can be saved. */
|
|
1407
1749
|
savePrefs(input) {
|
|
1408
1750
|
const current = reload();
|
|
@@ -1414,6 +1756,8 @@ export function apply(ctx, config = {}) {
|
|
|
1414
1756
|
save(input) {
|
|
1415
1757
|
const next = normalizeInput(input);
|
|
1416
1758
|
const current = reload();
|
|
1759
|
+
const beforeText = next.id === undefined ? '' : (current.personas.find((item) => item.id === next.id)?.text ?? '');
|
|
1760
|
+
const created = next.id === undefined;
|
|
1417
1761
|
let persona;
|
|
1418
1762
|
if (next.id === undefined) {
|
|
1419
1763
|
persona = { id: newId(), name: next.name, enabled: next.enabled, fallback: next.fallback, text: next.text, mode: next.mode, targets: next.targets };
|
|
@@ -1447,6 +1791,8 @@ export function apply(ctx, config = {}) {
|
|
|
1447
1791
|
}
|
|
1448
1792
|
}
|
|
1449
1793
|
commit(current);
|
|
1794
|
+
if (next.text !== beforeText)
|
|
1795
|
+
recordIfEnabled(persona.id, beforeText, next.text, created ? 'create' : 'update');
|
|
1450
1796
|
const result = view();
|
|
1451
1797
|
if (released.length > 0) {
|
|
1452
1798
|
result.notice = `「${persona.name}」现在是默认人设;「${released.join('」「')}」已取消默认`;
|
package/lib/remote.js
CHANGED
|
@@ -51,5 +51,8 @@ export const RPC_REMOTE = {
|
|
|
51
51
|
method("tunePersona", withInput()),
|
|
52
52
|
method("listModels", []),
|
|
53
53
|
method("savePrefs", withInput()),
|
|
54
|
+
method("listVersions", withInput()),
|
|
55
|
+
method("versionDiff", withInput()),
|
|
56
|
+
method("restoreVersion", withInput()),
|
|
54
57
|
],
|
|
55
58
|
};
|
package/lib/typert.js
CHANGED
|
@@ -40,6 +40,9 @@ export const TYPERT = {
|
|
|
40
40
|
{ name: "tunePersona", kind: "method", signature: "(input: unknown): Promise<unknown>" },
|
|
41
41
|
{ name: "listModels", kind: "method", signature: "(): Promise<unknown>" },
|
|
42
42
|
{ name: "savePrefs", kind: "method", signature: "(input: unknown): Promise<unknown>" },
|
|
43
|
+
{ name: "listVersions", kind: "method", signature: "(input: unknown): Promise<unknown>" },
|
|
44
|
+
{ name: "versionDiff", kind: "method", signature: "(input: unknown): Promise<unknown>" },
|
|
45
|
+
{ name: "restoreVersion", kind: "method", signature: "(input: unknown): Promise<unknown>" },
|
|
43
46
|
],
|
|
44
47
|
types: [],
|
|
45
48
|
},
|
|
@@ -219,5 +222,53 @@ export const TYPERT = {
|
|
|
219
222
|
],
|
|
220
223
|
result: result("Json"),
|
|
221
224
|
},
|
|
225
|
+
{
|
|
226
|
+
id: `dsh-agent-persona#agentPersona/listVersions`,
|
|
227
|
+
service: "agentPersona",
|
|
228
|
+
namespace: "agentPersona",
|
|
229
|
+
method: "listVersions",
|
|
230
|
+
invocation: direct,
|
|
231
|
+
parameters: [
|
|
232
|
+
{
|
|
233
|
+
name: "input",
|
|
234
|
+
wire: "input",
|
|
235
|
+
source: "json",
|
|
236
|
+
codec: jsonCodec("Json"),
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
result: result("Json"),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: `dsh-agent-persona#agentPersona/versionDiff`,
|
|
243
|
+
service: "agentPersona",
|
|
244
|
+
namespace: "agentPersona",
|
|
245
|
+
method: "versionDiff",
|
|
246
|
+
invocation: direct,
|
|
247
|
+
parameters: [
|
|
248
|
+
{
|
|
249
|
+
name: "input",
|
|
250
|
+
wire: "input",
|
|
251
|
+
source: "json",
|
|
252
|
+
codec: jsonCodec("Json"),
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
result: result("Json"),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: `dsh-agent-persona#agentPersona/restoreVersion`,
|
|
259
|
+
service: "agentPersona",
|
|
260
|
+
namespace: "agentPersona",
|
|
261
|
+
method: "restoreVersion",
|
|
262
|
+
invocation: direct,
|
|
263
|
+
parameters: [
|
|
264
|
+
{
|
|
265
|
+
name: "input",
|
|
266
|
+
wire: "input",
|
|
267
|
+
source: "json",
|
|
268
|
+
codec: jsonCodec("Json"),
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
result: result("Json"),
|
|
272
|
+
},
|
|
222
273
|
],
|
|
223
274
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agent-persona",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Workspace personas for DeepSeek Harness (DSH): create many system-prompt personas and scope each one by workspace path or session id (exact / prefix / regex / contains), managed from a Web settings page with Markdown editing and AI tuning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|