dsh-agent-persona 0.1.5 → 0.1.6
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 +28 -0
- package/README.md +19 -5
- package/README_EN.md +20 -4
- package/lib/client.js +153 -2
- 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,34 @@ 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.6] - 2026-10-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **人设版本历史(可选开关)。** 设置页新增「保存人设版本历史」,默认关闭。打开后每次保存正文都会
|
|
12
|
+
追加一条 diff 到 `personas.history.json`(与主存储同目录、0600):相等行只记行数,变更行原文保留,
|
|
13
|
+
因此一条记录的成本约等于这次改动本身,而不是整份正文。
|
|
14
|
+
- 编辑器底部新增「版本历史」按钮:左侧列出版本(时间、`+a / −r`、首个改动行),右侧显示该版相对上一版的
|
|
15
|
+
逐行 diff(红删绿增,未改动段折叠成行数)。「恢复到此版本」把选中版本写回当前人设,并且恢复本身也记成
|
|
16
|
+
一条新记录,所以恢复错了还能再回滚。
|
|
17
|
+
- 新 RPC:`listVersions` / `versionDiff` / `restoreVersion`(远程产物由 `npm run generate:remote` 生成)。
|
|
18
|
+
- 链式存储:第一条版本作为 base 快照,之后每版只在上一版上重放 diff;超过 `MAX_VERSIONS`(200)时把最旧的
|
|
19
|
+
几条折进 base,保证保留下来的版本仍可逐条重放。每条记录带内容 sha 摘要,历史被手改或损坏时重放会报错
|
|
20
|
+
而不是静默给出别的正文。
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- README 里的截图全部重作为动图(设置列表、卡片编辑、版本历史演示、会话页「人设」标签),
|
|
25
|
+
都取自 0.1.6 的实机操作:版本历史那张演示「打开 → 选较早版本 → 恢复」的完整回路。
|
|
26
|
+
同时去掉了与动图重复的静态 `versions.png`。
|
|
27
|
+
- 兼容性不变:仍按 0.1.5 的适配结论跑在 DSH `0.2.1-rc.2` 上(`dshReleases` 里的
|
|
28
|
+
`0.1.5-rc.1` / `0.1.5-rc.2` / `0.2.0-rc.2` / `0.2.1-rc.2` 标记不需要改动)。
|
|
29
|
+
|
|
30
|
+
### Notes
|
|
31
|
+
|
|
32
|
+
- 历史只记正文,不记名称、启用状态与适用范围;关掉开关后不再写新记录,已有历史仍可查看与恢复。
|
|
33
|
+
- 版本历史是独立于守卫的开关:守卫管的是「工具不许改存储」,历史管的是「改了就留痕」,两者互不依赖。
|
|
34
|
+
|
|
7
35
|
## [0.1.5] - 2026-10-20
|
|
8
36
|
|
|
9
37
|
### 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
|
|
|
@@ -723,6 +726,22 @@
|
|
|
723
726
|
@media (prefers-reduced-motion: reduce) {
|
|
724
727
|
[data-plugin="dsh-agent-persona"] * { transition: none !important; }
|
|
725
728
|
}
|
|
729
|
+
|
|
730
|
+
.wsp-versions { display: flex; flex-direction: column; gap: 12px; max-height: 58vh; }
|
|
731
|
+
.wsp-versions-list { display: flex; flex-direction: row; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
|
732
|
+
.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; }
|
|
733
|
+
.wsp-version-row:hover { border-color: var(--wsp-line-strong); }
|
|
734
|
+
.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)); }
|
|
735
|
+
.wsp-version-title { font-size: 13px; font-weight: 600; }
|
|
736
|
+
.wsp-version-meta { font-size: 11px; opacity: 0.72; font-variant-numeric: tabular-nums; }
|
|
737
|
+
.wsp-version-preview { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
738
|
+
.wsp-versions-diff { display: flex; flex-direction: column; gap: 8px; min-width: 0; overflow: hidden; }
|
|
739
|
+
.wsp-diff-head { font-size: 12px; opacity: 0.75; font-variant-numeric: tabular-nums; }
|
|
740
|
+
.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; }
|
|
741
|
+
.wsp-diff-line { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
|
|
742
|
+
.wsp-diff-add { background: rgba(46, 160, 67, 0.16); }
|
|
743
|
+
.wsp-diff-del { background: rgba(248, 81, 73, 0.14); }
|
|
744
|
+
.wsp-diff-skip { padding: 2px 10px; opacity: 0.5; font-size: 11px; }
|
|
726
745
|
`;
|
|
727
746
|
let styleElement;
|
|
728
747
|
const injectStyles = () => {
|
|
@@ -1153,6 +1172,11 @@
|
|
|
1153
1172
|
dropIndex: void 0,
|
|
1154
1173
|
history: void 0,
|
|
1155
1174
|
historyBusy: void 0,
|
|
1175
|
+
versions: void 0,
|
|
1176
|
+
versionsBusy: "",
|
|
1177
|
+
versionsError: "",
|
|
1178
|
+
versionDiff: void 0,
|
|
1179
|
+
versionIndex: void 0,
|
|
1156
1180
|
models: [],
|
|
1157
1181
|
model: "",
|
|
1158
1182
|
tuneOpen: false,
|
|
@@ -1323,6 +1347,55 @@
|
|
|
1323
1347
|
}
|
|
1324
1348
|
return items;
|
|
1325
1349
|
}
|
|
1350
|
+
/** Version history: list, diff, restore. Opt-in through the settings switch. */
|
|
1351
|
+
async openVersions(id) {
|
|
1352
|
+
const api = this.api();
|
|
1353
|
+
if (api === void 0 || typeof api.listVersions !== "function") {
|
|
1354
|
+
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" });
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
this.onChange({ versionsBusy: "list", versionsError: "", versions: { id }, versionDiff: void 0, versionIndex: void 0 });
|
|
1358
|
+
try {
|
|
1359
|
+
const data = this.unwrap(await api.listVersions({ id }));
|
|
1360
|
+
this.onChange({ versionsBusy: "", versions: data });
|
|
1361
|
+
await this.readVersion((data?.versions?.length ?? 0) - 1);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
this.onChange({ versionsBusy: "", versions: void 0, versionsError: String(error && error.message || error) });
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/** Load one version's diff; the modal swaps its right pane. */
|
|
1367
|
+
async readVersion(index) {
|
|
1368
|
+
const api = this.api();
|
|
1369
|
+
const versions = this.state.versions;
|
|
1370
|
+
if (api === void 0 || versions === void 0 || index < 0) return;
|
|
1371
|
+
this.onChange({ versionsBusy: "diff", versionIndex: index, versionsError: "" });
|
|
1372
|
+
try {
|
|
1373
|
+
const diff = this.unwrap(await api.versionDiff({ id: versions.id, index }));
|
|
1374
|
+
this.onChange({ versionsBusy: "", versionDiff: diff });
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
this.onChange({ versionsBusy: "", versionDiff: void 0, versionsError: String(error && error.message || error) });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/** Put one version back as the persona text (itself recorded as a new version). */
|
|
1380
|
+
async restoreVersion(index) {
|
|
1381
|
+
const api = this.api();
|
|
1382
|
+
const versions = this.state.versions;
|
|
1383
|
+
if (api === void 0 || versions === void 0) return;
|
|
1384
|
+
this.onChange({ versionsBusy: "restore", versionsError: "" });
|
|
1385
|
+
try {
|
|
1386
|
+
const view = this.unwrap(await api.restoreVersion({ id: versions.id, index }));
|
|
1387
|
+
const found = (view?.personas ?? []).find((item) => item.id === versions.id);
|
|
1388
|
+
this.onChange({
|
|
1389
|
+
view,
|
|
1390
|
+
versionsBusy: "",
|
|
1391
|
+
status: view?.notice ?? "\u5DF2\u6062\u590D",
|
|
1392
|
+
draft: this.state.openId === versions.id && found !== void 0 ? this.draftFrom(found) : this.state.draft
|
|
1393
|
+
});
|
|
1394
|
+
await this.openVersions(versions.id);
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
this.onChange({ versionsBusy: "", versionsError: String(error && error.message || error) });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1326
1399
|
/** Read a session's recent messages so the user can recognise it. */
|
|
1327
1400
|
async openHistory(sessionId) {
|
|
1328
1401
|
const api = this.api();
|
|
@@ -2267,6 +2340,14 @@ ${state.proposal.trim()}
|
|
|
2267
2340
|
h("div", { className: "wsp-foot", key: "foot" }, [
|
|
2268
2341
|
h("span", { className: "wsp-caption", key: "c" }, `${draft2.text.length} \u5B57 \xB7 \u2318/Ctrl+S \u4FDD\u5B58`),
|
|
2269
2342
|
h("div", { className: "wsp-actions", key: "a" }, [
|
|
2343
|
+
this.state.prefs?.history === true ? h(Button, {
|
|
2344
|
+
key: "ver",
|
|
2345
|
+
variant: "ghost",
|
|
2346
|
+
size: "sm",
|
|
2347
|
+
disabled: busy,
|
|
2348
|
+
title: "\u67E5\u770B\u8FD9\u6761\u4EBA\u8BBE\u7684\u7248\u672C\u5386\u53F2",
|
|
2349
|
+
onClick: () => void this.openVersions(draft2.id)
|
|
2350
|
+
}, "\u7248\u672C\u5386\u53F2") : null,
|
|
2270
2351
|
h(Button, { key: "dup", variant: "ghost", size: "sm", disabled: busy, onClick: () => void this.duplicate(draft2.id) }, "\u590D\u5236"),
|
|
2271
2352
|
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
2353
|
key: "del",
|
|
@@ -2501,6 +2582,13 @@ ${state.proposal.trim()}
|
|
|
2501
2582
|
label: "\u7981\u6B62\u6A21\u578B\u6539\u5199\u4EBA\u8BBE\u6587\u4EF6",
|
|
2502
2583
|
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
2584
|
onChange: (next) => this.setPref({ guard: next })
|
|
2585
|
+
}),
|
|
2586
|
+
h(Switch, {
|
|
2587
|
+
key: "history",
|
|
2588
|
+
checked: prefs.history === true,
|
|
2589
|
+
label: "\u4FDD\u5B58\u4EBA\u8BBE\u7248\u672C\u5386\u53F2",
|
|
2590
|
+
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",
|
|
2591
|
+
onChange: (next) => this.setPref({ history: next })
|
|
2504
2592
|
})
|
|
2505
2593
|
]);
|
|
2506
2594
|
const pendingDelete = state.confirmDelete === void 0 || state.confirmDelete === null ? void 0 : personas.find((item) => item.id === state.confirmDelete);
|
|
@@ -2515,6 +2603,69 @@ ${state.proposal.trim()}
|
|
|
2515
2603
|
h(Button, { key: "ok", variant: "primary", size: "sm", disabled: busy, onClick: () => void this.remove(pendingDelete.id) }, "\u5220\u9664")
|
|
2516
2604
|
])
|
|
2517
2605
|
}, 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`));
|
|
2606
|
+
const versionsState = state.versions;
|
|
2607
|
+
const diffState = state.versionDiff;
|
|
2608
|
+
const versionRows = versionsState === void 0 ? [] : [...versionsState.versions ?? []].reverse();
|
|
2609
|
+
const diffLines = [];
|
|
2610
|
+
if (diffState !== void 0) {
|
|
2611
|
+
for (const op of diffState.ops ?? []) {
|
|
2612
|
+
if (op.t === "=") {
|
|
2613
|
+
diffLines.push(h("div", { className: "wsp-diff-skip", key: `s${diffLines.length}` }, `\u22EF ${op.n} \u884C\u672A\u6539\u52A8`));
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2616
|
+
for (const line of op.lines ?? []) {
|
|
2617
|
+
diffLines.push(h("div", {
|
|
2618
|
+
className: `wsp-diff-line ${op.t === "+" ? "wsp-diff-add" : "wsp-diff-del"}`,
|
|
2619
|
+
key: `d${diffLines.length}`
|
|
2620
|
+
}, `${op.t === "+" ? "+" : "\u2212"} ${line === "" ? " " : line}`));
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
const versionsModal = versionsState === void 0 && state.versionsError === "" ? null : h(Modal, {
|
|
2625
|
+
key: "versions",
|
|
2626
|
+
open: true,
|
|
2627
|
+
onClose: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 }),
|
|
2628
|
+
title: `\u7248\u672C\u5386\u53F2${versionsState?.name === void 0 ? "" : ` \xB7 ${versionsState.name}`}`,
|
|
2629
|
+
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",
|
|
2630
|
+
footer: h("div", { className: "wsp-form-actions" }, [
|
|
2631
|
+
h(Button, {
|
|
2632
|
+
key: "close",
|
|
2633
|
+
variant: "outline",
|
|
2634
|
+
size: "sm",
|
|
2635
|
+
onClick: () => this.onChange({ versions: void 0, versionDiff: void 0, versionsError: "", versionIndex: void 0 })
|
|
2636
|
+
}, "\u5173\u95ED"),
|
|
2637
|
+
h(Button, {
|
|
2638
|
+
key: "restore",
|
|
2639
|
+
variant: "primary",
|
|
2640
|
+
size: "sm",
|
|
2641
|
+
disabled: state.versionsBusy !== "" || state.versionIndex === void 0 || diffState === void 0,
|
|
2642
|
+
onClick: () => void this.restoreVersion(state.versionIndex)
|
|
2643
|
+
}, state.versionsBusy === "restore" ? "\u6062\u590D\u4E2D\u2026" : "\u6062\u590D\u5230\u6B64\u7248\u672C")
|
|
2644
|
+
])
|
|
2645
|
+
}, h("div", { className: "wsp-versions" }, [
|
|
2646
|
+
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", {
|
|
2647
|
+
type: "button",
|
|
2648
|
+
key: version.index,
|
|
2649
|
+
className: `wsp-version-row${state.versionIndex === version.index ? " is-active" : ""}`,
|
|
2650
|
+
onClick: () => void this.readVersion(version.index)
|
|
2651
|
+
}, [
|
|
2652
|
+
h("span", { className: "wsp-version-title", key: "t" }, `\u7B2C ${version.index + 1} \u7248`),
|
|
2653
|
+
h(
|
|
2654
|
+
"span",
|
|
2655
|
+
{ className: "wsp-version-meta", key: "m" },
|
|
2656
|
+
`${String(version.at).replace("T", " ").slice(5, 19)}\u3000+${version.added} / \u2212${version.removed}`
|
|
2657
|
+
),
|
|
2658
|
+
h("span", { className: "wsp-version-preview", key: "p" }, version.preview || "\uFF08\u65E0\u53EF\u663E\u793A\u7684\u6539\u52A8\u884C\uFF09")
|
|
2659
|
+
]))),
|
|
2660
|
+
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") : [
|
|
2661
|
+
h(
|
|
2662
|
+
"div",
|
|
2663
|
+
{ className: "wsp-diff-head", key: "h" },
|
|
2664
|
+
`${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}`
|
|
2665
|
+
),
|
|
2666
|
+
h("div", { className: "wsp-diff-body", key: "b" }, diffLines)
|
|
2667
|
+
])
|
|
2668
|
+
]));
|
|
2518
2669
|
const health = view?.storeHealth;
|
|
2519
2670
|
const healthNote = health === void 0 || health.status === "ok" ? null : h("div", {
|
|
2520
2671
|
className: `wsp-alert wsp-alert-${health.status}`,
|
|
@@ -2621,7 +2772,7 @@ ${state.proposal.trim()}
|
|
|
2621
2772
|
])
|
|
2622
2773
|
])
|
|
2623
2774
|
]));
|
|
2624
|
-
return h("div", rootProps, [header, healthNote, prefsRow, transferRow, searchRow, hiddenNote, conflictNotice, h("div", { className: "wsp-cards", key: "cards" }, cards), status, historyModal, confirmModal, importModal]);
|
|
2775
|
+
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
2776
|
}
|
|
2626
2777
|
}
|
|
2627
2778
|
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.6",
|
|
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",
|