dsh-lpipe-memory 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +82 -2
- package/assets/icon.png +0 -0
- package/client/panel.js +254 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +263 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -4
- package/src/chain.mjs +28 -0
- package/src/compact.mjs +167 -0
- package/src/day.mjs +15 -0
- package/src/frames.mjs +12 -0
- package/src/index.js +183 -0
- package/src/media.mjs +97 -0
- package/src/mediapack.mjs +144 -0
- package/src/pack.mjs +94 -0
- package/src/paths.mjs +54 -0
- package/src/project.mjs +132 -0
- package/src/reader.mjs +284 -0
- package/src/rpc.mjs +77 -0
- package/src/stats.mjs +286 -0
- package/src/writer.mjs +155 -0
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-lpipe-memory · 记忆管线
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把会话原文逐字投影到长期文件,供星图(索引)与回忆(读取)按 `seq` 回取。
|
|
4
|
+
|
|
5
|
+
**索引可以重建,原文不能。** 这一层只做一件事:把该留的原文留住,一字不改,永不删除。
|
|
6
|
+
|
|
7
|
+
## 存什么
|
|
8
|
+
|
|
9
|
+
| 事件 | 存的内容 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `user/message` | `text`、`parts`、`source`(注入按 `source.kind` 打标,全部保留) |
|
|
12
|
+
| `assistant/message` | `text`、`reasoning`、`parts`、`usage`、`interrupted` |
|
|
13
|
+
| `tool/call` | `name`、`arguments`(模型产出的原始 JSON 字符串) |
|
|
14
|
+
| `tool/result` | `text`、`parts`、`isError`、`error` |
|
|
15
|
+
|
|
16
|
+
不存:`system/message`、`developer/message`、`request/header`、`turn/*`、`step/*`、`session/end-seed`、`assistant/attempt`、`assistant/message.stream`。`turn` / `step` 作为字段记在每行上。
|
|
17
|
+
|
|
18
|
+
## 存在哪
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
~/.dsh/life-memory/
|
|
22
|
+
├── sessions/<sessionId>/
|
|
23
|
+
│ ├── meta.json { id, title, startedAt, lastAt, rows, days:[…] }
|
|
24
|
+
│ ├── days/2026-11-05.jsonl.zst 一天一卷(正在写的那天 8 KB 一帧;过完的天重打成一条流)
|
|
25
|
+
│ ├── days/2026-11-05.idx 偏移表:seq → (帧号, 帧内偏移, 长度)
|
|
26
|
+
│ ├── days/2026-11-05.frames 帧表:帧号 → (文件偏移, 字节数)
|
|
27
|
+
│ ├── days/2026-11-05.media.zst 这一天的媒体包(追加式帧,图片原字节)
|
|
28
|
+
│ └── days/2026-11-05.media.idx sha256 → (帧位置, 帧长, 帧内偏移, 字节数)
|
|
29
|
+
└── state.json 全局游标:最后一条的 seq/hash,以及每个会话写到哪
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **一个对话一个文件夹**,互不干扰;文件夹里按天分卷。
|
|
33
|
+
- **`seq` 全局单调**,不按会话来——跨会话的时间线要一把统一的尺子;每行另有 `session` 字段。
|
|
34
|
+
- **4 点换天**(本地时间):04:00 到次日 03:59 归同一天。
|
|
35
|
+
- **哈希链**:每行 `prev`/`hash` 相连,跨文件跨天不断;改一个字,那一行就断。
|
|
36
|
+
- **冷日打包**:正在写的那一天保持小帧追加(只压新写的一段);已经过完的那一天,启动后会整天重打成一条 zstd 19 流。压缩等级不是关键,一次能看见多少连续文本才是:8 KB 一帧等于每 8 KB 清空一次字典,往小帧上提等级反而会变大。真实库实测 4.31 MB → 2.72 MB。
|
|
37
|
+
- **图片跟着那一天走**:图片与附件存在它所属那一天的媒体包里,不再有全局 `media/`;同一张图在同一天内只存一份,跨天各存一份——一天一个整体,搬走、备份、删掉都干净。图片本身已是压缩格式,媒体包用等级 1,实测只能再小 7.7%。
|
|
38
|
+
- **一行只存一次**:正文在 `text`、思考在 `reasoning`,`parts` 只留搬不走的结构块(图片 / 文件 / 工具结果)。早期版本把文本块也复制进 `parts`,占过整库 42% 的体积;`state.json` 的 `version: 2` 标记新格式,宿启动时发现旧版本会自动重建一次(旧库改名成 `life-memory.bak-<时间>` 留着)。
|
|
39
|
+
|
|
40
|
+
## 读
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { MemoryReader } from 'dsh-lpipe-memory'
|
|
44
|
+
|
|
45
|
+
const memory = new MemoryReader()
|
|
46
|
+
memory.read({ seq: 10421 }) // 一行原文(只解一帧)
|
|
47
|
+
memory.timeline({ from, to, limit, session }) // 按时间回放
|
|
48
|
+
memory.search({ text, session, limit }) // 子串搜索(冷路径,放 worker)
|
|
49
|
+
memory.verify({ from, to }) // 哈希链校验,返回问题清单
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
按 `seq` 取一行只解一帧(本机约 35 µs,命中帧缓存为零解压);不要每次都全扫——全扫是 228 ms/30 MB 的活,交给 worker。
|
|
53
|
+
|
|
54
|
+
## 安装
|
|
55
|
+
|
|
56
|
+
先装 `dsh-life-pipline`(公共页与 `clock`、`session` 等共用模块),再装本包,或者在仓库里:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node scripts/install-local.mjs
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
本包自带 `cordis.patch.yml`,只插自己一行;「Lpipe 设置」那一页仍由 `dsh-life-pipline` 提供。
|
|
63
|
+
|
|
64
|
+
## 还没有的
|
|
65
|
+
|
|
66
|
+
- 词表/倒排索引:属于星图(`dsh-lpipe-starmap`),本层只提供 `search` 的全扫兜底。
|
|
67
|
+
- 冷归档与分卷轮转。
|
|
68
|
+
|
|
69
|
+
许可:AGPL-3.0-or-later。
|
|
70
|
+
|
|
71
|
+
## 写入时机
|
|
72
|
+
|
|
73
|
+
一轮说完(`turn/end`)才把这一轮的行压成帧写进去:盘上不会出现半截的轮次。中间的消息、思考、工具调用都先攒在内存里;万一某轮没有 `turn/end`(60 秒保险计时到点)也会落一次,不会一直攒着。
|
|
74
|
+
|
|
75
|
+
## 从 DeepSeek 网页备份导入历史
|
|
76
|
+
|
|
77
|
+
`scripts/import-deepseek-web.mjs` 把网页版导出的 `history_messages`(`原始数据/015.json`)投影成记忆行:`REQUEST` → `user/message`,`RESPONSE` + `THINK` → `assistant/message`(正文与思考分开存),`TOOL_SEARCH` / `TOOL_OPEN` → `tool/call` 与 `tool/result`,`TIP` 丢弃。
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
node components/dsh-lpipe-memory/scripts/import-deepseek-web.mjs --backup "C:/path/to/备份/2026-11-05_212359" --dry-run
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
会话名默认 `deepseek-web-<chat_session.id>`,标题取会话标题。重复跑是幂等的(按会话内事件序号去重)。附件只有文件名,字节当年没有导出,所以只能留名字并标注 `mediaError`。
|
package/assets/icon.png
ADDED
|
Binary file
|
package/client/panel.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 记忆管线的设置块:只显示「原文有多大、最近几轮怎么长」。
|
|
5
|
+
* 页面本身归 dsh-life-pipline(「Lpipe 设置」),这里只往子槽里放一块。
|
|
6
|
+
*/
|
|
7
|
+
const React = require('react');
|
|
8
|
+
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
|
|
11
|
+
const RPC_ENDPOINT = 'dsh-lpipe-memory';
|
|
12
|
+
const PANEL_SLOT = 'lpipe.settings.panel';
|
|
13
|
+
const POLL_MS = 20000;
|
|
14
|
+
const TURNS = 30;
|
|
15
|
+
|
|
16
|
+
const COLORS = {
|
|
17
|
+
text: 'var(--dsw-alias-label-primary)',
|
|
18
|
+
muted: 'var(--dsw-alias-label-secondary)',
|
|
19
|
+
faint: 'var(--dsw-alias-label-tertiary)',
|
|
20
|
+
border: 'var(--dsw-alias-border-l)',
|
|
21
|
+
borderStrong: 'var(--dsw-alias-border-l2)',
|
|
22
|
+
bg: 'var(--dsw-alias-bg-layer-1)',
|
|
23
|
+
accent: '#5ec9b4',
|
|
24
|
+
disk: '#8b93f8',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const card = {
|
|
28
|
+
border: `1px solid ${COLORS.border}`,
|
|
29
|
+
borderRadius: 'var(--dsw-radius-lg)',
|
|
30
|
+
background: COLORS.bg,
|
|
31
|
+
padding: 16,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 15.0 MB / 812 KB:只保留两位有效位,别让小波动看起来像大变化。 */
|
|
35
|
+
function formatBytes(bytes) {
|
|
36
|
+
const value = Number(bytes) || 0;
|
|
37
|
+
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
38
|
+
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
39
|
+
if (value >= 1024) return `${Math.round(value / 1024)} KB`;
|
|
40
|
+
return `${Math.round(value)} B`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatClock(at) {
|
|
44
|
+
if (!Number.isFinite(at)) return '—';
|
|
45
|
+
const date = new Date(at);
|
|
46
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 把每条轮的值折成 0–100 的折线;原文与落盘共用一条纵轴,纵横都自适应。 */
|
|
50
|
+
function plotOf(turns) {
|
|
51
|
+
const up = turns.map((turn) => Number(turn.total) || 0);
|
|
52
|
+
if (up.length === 0) return null;
|
|
53
|
+
const rawDisk = turns.map((turn) => Number(turn.diskTotal));
|
|
54
|
+
const disk = rawDisk.every((value) => Number.isFinite(value) && value > 0) ? rawDisk : null;
|
|
55
|
+
const all = disk ? [...up, ...disk] : up;
|
|
56
|
+
const min = Math.min(...all);
|
|
57
|
+
const max = Math.max(...all);
|
|
58
|
+
const pad = Math.max((max - min) * 0.18, Math.max(max, 1) * 0.004);
|
|
59
|
+
const low = min - pad;
|
|
60
|
+
const high = max + pad;
|
|
61
|
+
const span = high - low || 1;
|
|
62
|
+
const line = (values) => values.map((value, index) => {
|
|
63
|
+
const x = values.length === 1 ? 100 : (index / (values.length - 1)) * 100;
|
|
64
|
+
const y = 100 - ((value - low) / span) * 100;
|
|
65
|
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
66
|
+
}).join(' ');
|
|
67
|
+
const upLine = line(up);
|
|
68
|
+
const diskLine = disk ? line(disk) : null;
|
|
69
|
+
return {
|
|
70
|
+
line: upLine,
|
|
71
|
+
disk: diskLine,
|
|
72
|
+
// 面积只铺在原文这条线下面,从上到下淡出;落盘线落在它里面,两者的距离就是省下的。
|
|
73
|
+
area: `0,100 ${upLine} 100,100`,
|
|
74
|
+
high,
|
|
75
|
+
low,
|
|
76
|
+
mid: (high + low) / 2,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function MemoryPanel({ rpcCall }) {
|
|
81
|
+
const [stats, setStats] = React.useState(null);
|
|
82
|
+
const [error, setError] = React.useState('');
|
|
83
|
+
|
|
84
|
+
const call = React.useCallback(async (method, payload) => {
|
|
85
|
+
const result = await rpcCall(method, payload ?? {});
|
|
86
|
+
if (!result || result.ok === false) {
|
|
87
|
+
const failure = new Error(result?.error?.message ?? '请求失败');
|
|
88
|
+
failure.code = result?.error?.code;
|
|
89
|
+
throw failure;
|
|
90
|
+
}
|
|
91
|
+
return result.value;
|
|
92
|
+
}, [rpcCall]);
|
|
93
|
+
|
|
94
|
+
const reload = React.useCallback(async () => {
|
|
95
|
+
try {
|
|
96
|
+
setStats(await call('memory.stats', { turns: TURNS }));
|
|
97
|
+
setError('');
|
|
98
|
+
} catch (failure) {
|
|
99
|
+
setError(String(failure?.message ?? failure));
|
|
100
|
+
}
|
|
101
|
+
}, [call]);
|
|
102
|
+
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
void reload();
|
|
105
|
+
}, [reload]);
|
|
106
|
+
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
const timer = setInterval(() => void reload(), POLL_MS);
|
|
109
|
+
return () => clearInterval(timer);
|
|
110
|
+
}, [reload]);
|
|
111
|
+
|
|
112
|
+
const turns = stats?.turns ?? [];
|
|
113
|
+
const plot = plotOf(turns);
|
|
114
|
+
const xLabels = turns.length > 1
|
|
115
|
+
? h('div', {
|
|
116
|
+
style: {
|
|
117
|
+
display: 'flex', justifyContent: 'space-between', marginTop: 5,
|
|
118
|
+
fontSize: 10, color: COLORS.faint,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
h('span', null, formatClock(turns[0].at)),
|
|
122
|
+
h('span', null, formatClock(turns[turns.length - 1].at)))
|
|
123
|
+
: null;
|
|
124
|
+
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
125
|
+
|
|
126
|
+
return h('section', null,
|
|
127
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 } },
|
|
128
|
+
h('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600, color: COLORS.text } }, 'Lpipe Memory'),
|
|
129
|
+
h('span', {
|
|
130
|
+
style: {
|
|
131
|
+
fontSize: 11, padding: '2px 8px', borderRadius: 999,
|
|
132
|
+
background: 'rgba(94, 201, 180, 0.14)', color: COLORS.accent,
|
|
133
|
+
border: '1px solid rgba(94, 201, 180, 0.28)',
|
|
134
|
+
},
|
|
135
|
+
}, '记忆管线'),
|
|
136
|
+
h('span', { style: { marginLeft: 'auto', display: 'flex', gap: 12, fontSize: 10, color: COLORS.faint } },
|
|
137
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
138
|
+
h('i', { style: { width: 7, height: 2, borderRadius: 1, background: COLORS.accent } }), '原文'),
|
|
139
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
140
|
+
h('i', { style: { width: 7, height: 2, borderRadius: 1, background: COLORS.disk } }), '落盘'))),
|
|
141
|
+
|
|
142
|
+
error
|
|
143
|
+
? h('div', { style: { marginBottom: 12, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } },
|
|
144
|
+
`读取记忆大小失败:${error}`)
|
|
145
|
+
: null,
|
|
146
|
+
|
|
147
|
+
h('div', { style: card },
|
|
148
|
+
h('div', { style: { display: 'flex', alignItems: 'baseline', gap: 36, flexWrap: 'wrap' } },
|
|
149
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'baseline', gap: 6 } },
|
|
150
|
+
h('span', { style: { fontSize: 21, fontWeight: 600, color: COLORS.text, position: 'relative', top: -3 } }, '原文'),
|
|
151
|
+
h('span', { style: { fontSize: 21, fontWeight: 600, color: COLORS.text } },
|
|
152
|
+
formatBytes(stats?.total ?? 0)),
|
|
153
|
+
lastTurn
|
|
154
|
+
? h('span', {
|
|
155
|
+
style: { fontSize: 11, color: COLORS.accent, display: 'inline-flex', alignItems: 'baseline', gap: 4 },
|
|
156
|
+
},
|
|
157
|
+
h('span', { style: { position: 'relative', top: -1.5 } }, '最近'),
|
|
158
|
+
h('span', null, `+${formatBytes(lastTurn.added)}`))
|
|
159
|
+
: null),
|
|
160
|
+
|
|
161
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'baseline', gap: 6 } },
|
|
162
|
+
h('span', { style: { fontSize: 15, fontWeight: 600, color: COLORS.text, position: 'relative', top: -2 } }, '落盘'),
|
|
163
|
+
h('span', { style: { fontSize: 15, fontWeight: 600, color: COLORS.text } },
|
|
164
|
+
formatBytes(stats?.disk ?? 0)),
|
|
165
|
+
lastTurn
|
|
166
|
+
? h('span', {
|
|
167
|
+
style: { fontSize: 11, color: COLORS.disk, display: 'inline-flex', alignItems: 'baseline', gap: 4 },
|
|
168
|
+
},
|
|
169
|
+
h('span', { style: { position: 'relative', top: -1.5 } }, '最近'),
|
|
170
|
+
h('span', null, `+${formatBytes(lastTurn.diskAdded)}`))
|
|
171
|
+
: null)),
|
|
172
|
+
|
|
173
|
+
plot
|
|
174
|
+
? h('div', {
|
|
175
|
+
style: {
|
|
176
|
+
display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 8, marginTop: 12,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
h('div', {
|
|
180
|
+
style: {
|
|
181
|
+
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
|
182
|
+
gridColumn: 1, padding: '2px 0 1px', fontSize: 9.5,
|
|
183
|
+
color: COLORS.faint, textAlign: 'left', whiteSpace: 'nowrap',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
h('span', null, formatBytes(plot.high)),
|
|
187
|
+
h('span', null, formatBytes(plot.mid)),
|
|
188
|
+
h('span', null, formatBytes(plot.low))),
|
|
189
|
+
h('svg', {
|
|
190
|
+
viewBox: '0 0 100 100',
|
|
191
|
+
preserveAspectRatio: 'none',
|
|
192
|
+
style: { width: '100%', minWidth: 0, height: 86, display: 'block' },
|
|
193
|
+
'aria-label': '记忆大小曲线',
|
|
194
|
+
},
|
|
195
|
+
h('line', {
|
|
196
|
+
x1: 0, y1: 0, x2: 0, y2: 100,
|
|
197
|
+
stroke: 'currentColor', strokeOpacity: 0.18,
|
|
198
|
+
vectorEffect: 'non-scaling-stroke',
|
|
199
|
+
}),
|
|
200
|
+
h('line', {
|
|
201
|
+
x1: 0, y1: 33, x2: 100, y2: 33,
|
|
202
|
+
stroke: 'currentColor', strokeOpacity: 0.12, strokeDasharray: '3 4',
|
|
203
|
+
vectorEffect: 'non-scaling-stroke',
|
|
204
|
+
}),
|
|
205
|
+
h('line', {
|
|
206
|
+
x1: 0, y1: 66, x2: 100, y2: 66,
|
|
207
|
+
stroke: 'currentColor', strokeOpacity: 0.12, strokeDasharray: '3 4',
|
|
208
|
+
vectorEffect: 'non-scaling-stroke',
|
|
209
|
+
}),
|
|
210
|
+
h('defs', null,
|
|
211
|
+
h('linearGradient', { id: 'memUp', x1: 0, y1: 0, x2: 0, y2: 1 },
|
|
212
|
+
h('stop', { offset: '0%', stopColor: COLORS.accent, stopOpacity: 0.22 }),
|
|
213
|
+
h('stop', { offset: '100%', stopColor: COLORS.accent, stopOpacity: 0 }))),
|
|
214
|
+
h('polygon', { points: plot.area, fill: 'url(#memUp)' }),
|
|
215
|
+
h('polyline', {
|
|
216
|
+
points: plot.line, fill: 'none', stroke: COLORS.accent, strokeWidth: 1.6,
|
|
217
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke',
|
|
218
|
+
}),
|
|
219
|
+
plot.disk
|
|
220
|
+
? h('polyline', {
|
|
221
|
+
points: plot.disk, fill: 'none', stroke: COLORS.disk, strokeWidth: 1.4,
|
|
222
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke',
|
|
223
|
+
})
|
|
224
|
+
: null),
|
|
225
|
+
h('div', null),
|
|
226
|
+
xLabels)
|
|
227
|
+
: h('div', { style: { marginTop: 12, fontSize: 12, color: COLORS.muted } }, '还没有落盘的轮次。'),
|
|
228
|
+
|
|
229
|
+
h('div', { style: { marginTop: 8, fontSize: 11, color: COLORS.faint } },
|
|
230
|
+
'两条线共用一条纵轴:青色是原文(未压缩),蓝紫色是真实落盘,中间那段距离就是 zstd 省下的。')));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const name = 'dsh-lpipe-memory-settings';
|
|
234
|
+
const inject = ['slots', 'connection'];
|
|
235
|
+
|
|
236
|
+
function apply(ctx) {
|
|
237
|
+
const rpcCall = (method, payload, signal) =>
|
|
238
|
+
ctx.connection.rpc.call('/api', RPC_ENDPOINT, { method, payload: payload ?? {} }, signal);
|
|
239
|
+
|
|
240
|
+
ctx.slots.inject(PANEL_SLOT, () => ctx.slots.register({
|
|
241
|
+
name: PANEL_SLOT,
|
|
242
|
+
id: 'memory',
|
|
243
|
+
order: 30,
|
|
244
|
+
label: () => '记忆管线',
|
|
245
|
+
inject: () => ({ rpcCall }),
|
|
246
|
+
}, MemoryPanel));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
name,
|
|
251
|
+
inject,
|
|
252
|
+
apply,
|
|
253
|
+
__test__: { MemoryPanel, PANEL_SLOT, formatBytes, formatClock, plotOf, TURNS },
|
|
254
|
+
};
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-lpipe-memory",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 记忆管线的设置块:只显示「原文有多大、最近几轮怎么长」。
|
|
10
|
+
* 页面本身归 dsh-life-pipline(「Lpipe 设置」),这里只往子槽里放一块。
|
|
11
|
+
*/
|
|
12
|
+
const React = require('react');
|
|
13
|
+
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
|
|
16
|
+
const RPC_ENDPOINT = 'dsh-lpipe-memory';
|
|
17
|
+
const PANEL_SLOT = 'lpipe.settings.panel';
|
|
18
|
+
const POLL_MS = 20000;
|
|
19
|
+
const TURNS = 30;
|
|
20
|
+
|
|
21
|
+
const COLORS = {
|
|
22
|
+
text: 'var(--dsw-alias-label-primary)',
|
|
23
|
+
muted: 'var(--dsw-alias-label-secondary)',
|
|
24
|
+
faint: 'var(--dsw-alias-label-tertiary)',
|
|
25
|
+
border: 'var(--dsw-alias-border-l)',
|
|
26
|
+
borderStrong: 'var(--dsw-alias-border-l2)',
|
|
27
|
+
bg: 'var(--dsw-alias-bg-layer-1)',
|
|
28
|
+
accent: '#5ec9b4',
|
|
29
|
+
disk: '#8b93f8',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const card = {
|
|
33
|
+
border: `1px solid ${COLORS.border}`,
|
|
34
|
+
borderRadius: 'var(--dsw-radius-lg)',
|
|
35
|
+
background: COLORS.bg,
|
|
36
|
+
padding: 16,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** 15.0 MB / 812 KB:只保留两位有效位,别让小波动看起来像大变化。 */
|
|
40
|
+
function formatBytes(bytes) {
|
|
41
|
+
const value = Number(bytes) || 0;
|
|
42
|
+
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
43
|
+
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
44
|
+
if (value >= 1024) return `${Math.round(value / 1024)} KB`;
|
|
45
|
+
return `${Math.round(value)} B`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatClock(at) {
|
|
49
|
+
if (!Number.isFinite(at)) return '—';
|
|
50
|
+
const date = new Date(at);
|
|
51
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 把每条轮的值折成 0–100 的折线;原文与落盘共用一条纵轴,纵横都自适应。 */
|
|
55
|
+
function plotOf(turns) {
|
|
56
|
+
const up = turns.map((turn) => Number(turn.total) || 0);
|
|
57
|
+
if (up.length === 0) return null;
|
|
58
|
+
const rawDisk = turns.map((turn) => Number(turn.diskTotal));
|
|
59
|
+
const disk = rawDisk.every((value) => Number.isFinite(value) && value > 0) ? rawDisk : null;
|
|
60
|
+
const all = disk ? [...up, ...disk] : up;
|
|
61
|
+
const min = Math.min(...all);
|
|
62
|
+
const max = Math.max(...all);
|
|
63
|
+
const pad = Math.max((max - min) * 0.18, Math.max(max, 1) * 0.004);
|
|
64
|
+
const low = min - pad;
|
|
65
|
+
const high = max + pad;
|
|
66
|
+
const span = high - low || 1;
|
|
67
|
+
const line = (values) => values.map((value, index) => {
|
|
68
|
+
const x = values.length === 1 ? 100 : (index / (values.length - 1)) * 100;
|
|
69
|
+
const y = 100 - ((value - low) / span) * 100;
|
|
70
|
+
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
|
71
|
+
}).join(' ');
|
|
72
|
+
const upLine = line(up);
|
|
73
|
+
const diskLine = disk ? line(disk) : null;
|
|
74
|
+
return {
|
|
75
|
+
line: upLine,
|
|
76
|
+
disk: diskLine,
|
|
77
|
+
// 面积只铺在原文这条线下面,从上到下淡出;落盘线落在它里面,两者的距离就是省下的。
|
|
78
|
+
area: `0,100 ${upLine} 100,100`,
|
|
79
|
+
high,
|
|
80
|
+
low,
|
|
81
|
+
mid: (high + low) / 2,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function MemoryPanel({ rpcCall }) {
|
|
86
|
+
const [stats, setStats] = React.useState(null);
|
|
87
|
+
const [error, setError] = React.useState('');
|
|
88
|
+
|
|
89
|
+
const call = React.useCallback(async (method, payload) => {
|
|
90
|
+
const result = await rpcCall(method, payload ?? {});
|
|
91
|
+
if (!result || result.ok === false) {
|
|
92
|
+
const failure = new Error(result?.error?.message ?? '请求失败');
|
|
93
|
+
failure.code = result?.error?.code;
|
|
94
|
+
throw failure;
|
|
95
|
+
}
|
|
96
|
+
return result.value;
|
|
97
|
+
}, [rpcCall]);
|
|
98
|
+
|
|
99
|
+
const reload = React.useCallback(async () => {
|
|
100
|
+
try {
|
|
101
|
+
setStats(await call('memory.stats', { turns: TURNS }));
|
|
102
|
+
setError('');
|
|
103
|
+
} catch (failure) {
|
|
104
|
+
setError(String(failure?.message ?? failure));
|
|
105
|
+
}
|
|
106
|
+
}, [call]);
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
void reload();
|
|
110
|
+
}, [reload]);
|
|
111
|
+
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
const timer = setInterval(() => void reload(), POLL_MS);
|
|
114
|
+
return () => clearInterval(timer);
|
|
115
|
+
}, [reload]);
|
|
116
|
+
|
|
117
|
+
const turns = stats?.turns ?? [];
|
|
118
|
+
const plot = plotOf(turns);
|
|
119
|
+
const xLabels = turns.length > 1
|
|
120
|
+
? h('div', {
|
|
121
|
+
style: {
|
|
122
|
+
display: 'flex', justifyContent: 'space-between', marginTop: 5,
|
|
123
|
+
fontSize: 10, color: COLORS.faint,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
h('span', null, formatClock(turns[0].at)),
|
|
127
|
+
h('span', null, formatClock(turns[turns.length - 1].at)))
|
|
128
|
+
: null;
|
|
129
|
+
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
130
|
+
|
|
131
|
+
return h('section', null,
|
|
132
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 } },
|
|
133
|
+
h('h3', { style: { margin: 0, fontSize: 15, fontWeight: 600, color: COLORS.text } }, 'Lpipe Memory'),
|
|
134
|
+
h('span', {
|
|
135
|
+
style: {
|
|
136
|
+
fontSize: 11, padding: '2px 8px', borderRadius: 999,
|
|
137
|
+
background: 'rgba(94, 201, 180, 0.14)', color: COLORS.accent,
|
|
138
|
+
border: '1px solid rgba(94, 201, 180, 0.28)',
|
|
139
|
+
},
|
|
140
|
+
}, '记忆管线'),
|
|
141
|
+
h('span', { style: { marginLeft: 'auto', display: 'flex', gap: 12, fontSize: 10, color: COLORS.faint } },
|
|
142
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
143
|
+
h('i', { style: { width: 7, height: 2, borderRadius: 1, background: COLORS.accent } }), '原文'),
|
|
144
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
145
|
+
h('i', { style: { width: 7, height: 2, borderRadius: 1, background: COLORS.disk } }), '落盘'))),
|
|
146
|
+
|
|
147
|
+
error
|
|
148
|
+
? h('div', { style: { marginBottom: 12, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } },
|
|
149
|
+
`读取记忆大小失败:${error}`)
|
|
150
|
+
: null,
|
|
151
|
+
|
|
152
|
+
h('div', { style: card },
|
|
153
|
+
h('div', { style: { display: 'flex', alignItems: 'baseline', gap: 36, flexWrap: 'wrap' } },
|
|
154
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'baseline', gap: 6 } },
|
|
155
|
+
h('span', { style: { fontSize: 21, fontWeight: 600, color: COLORS.text, position: 'relative', top: -3 } }, '原文'),
|
|
156
|
+
h('span', { style: { fontSize: 21, fontWeight: 600, color: COLORS.text } },
|
|
157
|
+
formatBytes(stats?.total ?? 0)),
|
|
158
|
+
lastTurn
|
|
159
|
+
? h('span', {
|
|
160
|
+
style: { fontSize: 11, color: COLORS.accent, display: 'inline-flex', alignItems: 'baseline', gap: 4 },
|
|
161
|
+
},
|
|
162
|
+
h('span', { style: { position: 'relative', top: -1.5 } }, '最近'),
|
|
163
|
+
h('span', null, `+${formatBytes(lastTurn.added)}`))
|
|
164
|
+
: null),
|
|
165
|
+
|
|
166
|
+
h('span', { style: { display: 'inline-flex', alignItems: 'baseline', gap: 6 } },
|
|
167
|
+
h('span', { style: { fontSize: 15, fontWeight: 600, color: COLORS.text, position: 'relative', top: -2 } }, '落盘'),
|
|
168
|
+
h('span', { style: { fontSize: 15, fontWeight: 600, color: COLORS.text } },
|
|
169
|
+
formatBytes(stats?.disk ?? 0)),
|
|
170
|
+
lastTurn
|
|
171
|
+
? h('span', {
|
|
172
|
+
style: { fontSize: 11, color: COLORS.disk, display: 'inline-flex', alignItems: 'baseline', gap: 4 },
|
|
173
|
+
},
|
|
174
|
+
h('span', { style: { position: 'relative', top: -1.5 } }, '最近'),
|
|
175
|
+
h('span', null, `+${formatBytes(lastTurn.diskAdded)}`))
|
|
176
|
+
: null)),
|
|
177
|
+
|
|
178
|
+
plot
|
|
179
|
+
? h('div', {
|
|
180
|
+
style: {
|
|
181
|
+
display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 8, marginTop: 12,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
h('div', {
|
|
185
|
+
style: {
|
|
186
|
+
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
|
187
|
+
gridColumn: 1, padding: '2px 0 1px', fontSize: 9.5,
|
|
188
|
+
color: COLORS.faint, textAlign: 'left', whiteSpace: 'nowrap',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
h('span', null, formatBytes(plot.high)),
|
|
192
|
+
h('span', null, formatBytes(plot.mid)),
|
|
193
|
+
h('span', null, formatBytes(plot.low))),
|
|
194
|
+
h('svg', {
|
|
195
|
+
viewBox: '0 0 100 100',
|
|
196
|
+
preserveAspectRatio: 'none',
|
|
197
|
+
style: { width: '100%', minWidth: 0, height: 86, display: 'block' },
|
|
198
|
+
'aria-label': '记忆大小曲线',
|
|
199
|
+
},
|
|
200
|
+
h('line', {
|
|
201
|
+
x1: 0, y1: 0, x2: 0, y2: 100,
|
|
202
|
+
stroke: 'currentColor', strokeOpacity: 0.18,
|
|
203
|
+
vectorEffect: 'non-scaling-stroke',
|
|
204
|
+
}),
|
|
205
|
+
h('line', {
|
|
206
|
+
x1: 0, y1: 33, x2: 100, y2: 33,
|
|
207
|
+
stroke: 'currentColor', strokeOpacity: 0.12, strokeDasharray: '3 4',
|
|
208
|
+
vectorEffect: 'non-scaling-stroke',
|
|
209
|
+
}),
|
|
210
|
+
h('line', {
|
|
211
|
+
x1: 0, y1: 66, x2: 100, y2: 66,
|
|
212
|
+
stroke: 'currentColor', strokeOpacity: 0.12, strokeDasharray: '3 4',
|
|
213
|
+
vectorEffect: 'non-scaling-stroke',
|
|
214
|
+
}),
|
|
215
|
+
h('defs', null,
|
|
216
|
+
h('linearGradient', { id: 'memUp', x1: 0, y1: 0, x2: 0, y2: 1 },
|
|
217
|
+
h('stop', { offset: '0%', stopColor: COLORS.accent, stopOpacity: 0.22 }),
|
|
218
|
+
h('stop', { offset: '100%', stopColor: COLORS.accent, stopOpacity: 0 }))),
|
|
219
|
+
h('polygon', { points: plot.area, fill: 'url(#memUp)' }),
|
|
220
|
+
h('polyline', {
|
|
221
|
+
points: plot.line, fill: 'none', stroke: COLORS.accent, strokeWidth: 1.6,
|
|
222
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke',
|
|
223
|
+
}),
|
|
224
|
+
plot.disk
|
|
225
|
+
? h('polyline', {
|
|
226
|
+
points: plot.disk, fill: 'none', stroke: COLORS.disk, strokeWidth: 1.4,
|
|
227
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke',
|
|
228
|
+
})
|
|
229
|
+
: null),
|
|
230
|
+
h('div', null),
|
|
231
|
+
xLabels)
|
|
232
|
+
: h('div', { style: { marginTop: 12, fontSize: 12, color: COLORS.muted } }, '还没有落盘的轮次。'),
|
|
233
|
+
|
|
234
|
+
h('div', { style: { marginTop: 8, fontSize: 11, color: COLORS.faint } },
|
|
235
|
+
'两条线共用一条纵轴:青色是原文(未压缩),蓝紫色是真实落盘,中间那段距离就是 zstd 省下的。')));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const name = 'dsh-lpipe-memory-settings';
|
|
239
|
+
const inject = ['slots', 'connection'];
|
|
240
|
+
|
|
241
|
+
function apply(ctx) {
|
|
242
|
+
const rpcCall = (method, payload, signal) =>
|
|
243
|
+
ctx.connection.rpc.call('/api', RPC_ENDPOINT, { method, payload: payload ?? {} }, signal);
|
|
244
|
+
|
|
245
|
+
ctx.slots.inject(PANEL_SLOT, () => ctx.slots.register({
|
|
246
|
+
name: PANEL_SLOT,
|
|
247
|
+
id: 'memory',
|
|
248
|
+
order: 30,
|
|
249
|
+
label: () => '记忆管线',
|
|
250
|
+
inject: () => ({ rpcCall }),
|
|
251
|
+
}, MemoryPanel));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
name,
|
|
256
|
+
inject,
|
|
257
|
+
apply,
|
|
258
|
+
__test__: { MemoryPanel, PANEL_SLOT, formatBytes, formatClock, plotOf, TURNS },
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return module.exports;
|
|
262
|
+
}
|
|
263
|
+
});
|
package/locale/en.json
ADDED