dsh-memory-engine 0.7.0-beta.2b → 0.7.0-beta.4
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/README.md +44 -11
- package/lib/client.js +429 -31
- package/lib/client.src.js +324 -15
- package/lib/config.js +32 -4
- package/lib/map.js +10 -3
- package/lib/render/canvas.js +105 -16
- package/lib/render/layout.mjs +43 -0
- package/lib/render/render.mjs +23 -2
- package/lib/routes.js +93 -8
- package/lib/store.js +47 -2
- package/package.json +1 -1
package/lib/render/canvas.js
CHANGED
|
@@ -6,13 +6,14 @@ export function createRenderer(canvas, data, options = {}) {
|
|
|
6
6
|
if (!ctx) throw new Error('Canvas 2D 不可用');
|
|
7
7
|
const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
|
|
8
8
|
const fallback = [145,205,255];
|
|
9
|
-
let nodes = [], links = [], hubs = [];
|
|
9
|
+
let nodes = [], links = [], hubs = [], linkTwinkle = [];
|
|
10
10
|
const byId = new Map(), adjacency = new Map(), sprites = new Map();
|
|
11
11
|
const pulses = [], firing = new Map(), selections = new Set();
|
|
12
12
|
if (typeof options.onSelect === 'function') selections.add(options.onSelect);
|
|
13
13
|
let seed = 987654321;
|
|
14
14
|
const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
|
|
15
15
|
let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
|
|
16
|
+
let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
|
|
16
17
|
let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
|
|
17
18
|
|
|
18
19
|
function glowSprite(rgb) {
|
|
@@ -27,8 +28,21 @@ function glowSprite(rgb) {
|
|
|
27
28
|
return off;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// 字符串 → 32 位无符号哈希。给连线的闪烁参数做种子:
|
|
32
|
+
// 用 id 而不是 Math.random,保证同一条线在每次打开时节奏一致(不会每次刷新都跳),
|
|
33
|
+
// 但不同连线之间互不同步。
|
|
34
|
+
function hashString(text) {
|
|
35
|
+
let hash = 2166136261;
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
hash ^= text.charCodeAt(i);
|
|
38
|
+
hash = Math.imul(hash, 16777619);
|
|
39
|
+
}
|
|
40
|
+
return hash >>> 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
function setData(nextData) {
|
|
31
44
|
if (destroyed) return;
|
|
45
|
+
spinSpeed=nextData.spinSpeed??0.35;
|
|
32
46
|
nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
|
|
33
47
|
links = nextData.links.map(link => [...link]);
|
|
34
48
|
byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
|
|
@@ -39,6 +53,24 @@ function glowSprite(rgb) {
|
|
|
39
53
|
if (adjacency.has(left) && adjacency.has(right)) { adjacency.get(left).push(right); adjacency.get(right).push(left); }
|
|
40
54
|
}
|
|
41
55
|
hubs = nodes.filter(star => star.degree > 0).sort((left,right) => right.degree-left.degree).slice(0,6);
|
|
56
|
+
// 每条连线一份自己的闪烁参数:相位和频率都不一样,整体才不会"齐刷刷"地一起亮。
|
|
57
|
+
// 用连线两端 id 的哈希做种子,保证同一条线每次打开图的节奏一致(不会跳变),
|
|
58
|
+
// 但线与线之间互不同步。
|
|
59
|
+
linkTwinkle = links.map(([left, right]) => {
|
|
60
|
+
const h = hashString(left + '|' + right);
|
|
61
|
+
return {
|
|
62
|
+
// 频率调低:周期从 4~18 秒放慢到 18~55 秒,变成"慢慢呼吸"而不是"闪"
|
|
63
|
+
freq: 0.000114 + (h % 1000) / 1000 * 0.000235,
|
|
64
|
+
phase: (Math.floor(h / 1000) % 6283) / 1000, // 0 ~ 6.28,彼此错开
|
|
65
|
+
amp: 0.05 + (Math.floor(h / 7e6) % 100) / 100 * 0.12,
|
|
66
|
+
// 弯曲:一半往左弯一半往右弯,弯度 8%~24%。
|
|
67
|
+
// 之前所有线都朝同一侧弯,视觉上像漩涡,边界区域就显空。
|
|
68
|
+
bow: (h % 2 === 0 ? 1 : -1) * (0.08 + (Math.floor(h / 13) % 100) / 100 * 0.16),
|
|
69
|
+
// 端点偏移:同一个节点被很多线连时,让它们从不同角度进来,散成扇面而不是叠成一条
|
|
70
|
+
jitterStart: ((Math.floor(h / 31) % 100) / 100 - 0.5) * 1.6,
|
|
71
|
+
jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
42
74
|
for (const star of nodes) {
|
|
43
75
|
star._rgb = colors[star.kind] || fallback;
|
|
44
76
|
const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
|
|
@@ -74,9 +106,19 @@ function project(pos) {
|
|
|
74
106
|
}
|
|
75
107
|
function fire(id,now) {
|
|
76
108
|
firing.set(id,1);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
const neighbours=adjacency.get(id)||[];
|
|
110
|
+
// 不再"点亮整个扇面":只随机挑 2~4 条邻居。
|
|
111
|
+
// 一次把几十条边同时点亮,看上去就是全图一起闪。
|
|
112
|
+
const count=Math.min(neighbours.length,2+Math.floor(Math.random()*3));
|
|
113
|
+
const picked=new Set();
|
|
114
|
+
let guard=0;
|
|
115
|
+
while (picked.size<count && neighbours.length && guard++<200) {
|
|
116
|
+
picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
|
|
117
|
+
}
|
|
118
|
+
for (const other of picked) {
|
|
119
|
+
// 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
|
|
120
|
+
firing.set(other,Math.max(0.26,firing.get(other)||0));
|
|
121
|
+
pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
|
|
80
122
|
}
|
|
81
123
|
}
|
|
82
124
|
|
|
@@ -96,6 +138,7 @@ function fire(id,now) {
|
|
|
96
138
|
}
|
|
97
139
|
|
|
98
140
|
function onPointerDown(event) {
|
|
141
|
+
resumeAt=Infinity;
|
|
99
142
|
autoSpin=false; drag={x:event.clientX,y:event.clientY,moved:0,pointerId:event.pointerId};
|
|
100
143
|
canvas.setPointerCapture(event.pointerId);
|
|
101
144
|
}
|
|
@@ -106,6 +149,7 @@ function fire(id,now) {
|
|
|
106
149
|
rotY+=dx*0.006; rotX=Math.max(-1.5,Math.min(1.5,rotX+dy*0.006));
|
|
107
150
|
}
|
|
108
151
|
function releaseDrag() {
|
|
152
|
+
if (drag && running) resumeAt=performance.now()+2000;
|
|
109
153
|
if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId);
|
|
110
154
|
drag=null;
|
|
111
155
|
}
|
|
@@ -124,13 +168,21 @@ function fire(id,now) {
|
|
|
124
168
|
|
|
125
169
|
function frame(now) {
|
|
126
170
|
const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
|
|
127
|
-
if (autoSpin)
|
|
128
|
-
if (
|
|
171
|
+
if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
|
|
172
|
+
if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
|
|
173
|
+
if (now>nextFire) {
|
|
174
|
+
// 心跳节奏别太机械:间隔随机,偶尔连发两个,偶尔安静久一点
|
|
175
|
+
const burst=Math.random()<0.22 ? 2 : 1;
|
|
176
|
+
for (let i=0;i<burst && hubs.length;i++) {
|
|
177
|
+
fire(hubs[Math.floor(Math.random()*hubs.length)].id,now);
|
|
178
|
+
}
|
|
179
|
+
nextFire=now+1400+Math.random()*3200;
|
|
180
|
+
}
|
|
129
181
|
ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
|
|
130
182
|
ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
|
|
131
183
|
for (let index=0;index<20;index++) {
|
|
132
184
|
const y=index/20*height;
|
|
133
|
-
ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.
|
|
185
|
+
ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.00007+index)*30,width*.7,y-Math.cos(now*.00006+index)*30,width,y);ctx.stroke();
|
|
134
186
|
}
|
|
135
187
|
const points=[];
|
|
136
188
|
for (const star of nodes) {
|
|
@@ -138,19 +190,39 @@ function frame(now) {
|
|
|
138
190
|
if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
|
|
139
191
|
points.push({star,p});
|
|
140
192
|
}
|
|
141
|
-
points.sort((left,right)=>right.p.depth-left.p.depth);
|
|
193
|
+
points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
|
|
142
194
|
const screen=new Map(points.map(point=>[point.star.id,point]));
|
|
143
195
|
ctx.globalCompositeOperation='lighter';
|
|
144
|
-
for (
|
|
196
|
+
for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
|
|
197
|
+
const [left,right]=links[linkIndex];
|
|
145
198
|
const first=screen.get(left),second=screen.get(right);
|
|
146
199
|
if (!first||!second) continue;
|
|
147
200
|
const act=Math.max(firing.get(left)||0,firing.get(right)||0);
|
|
148
|
-
|
|
201
|
+
// 连线自己也会呼吸:每条线有独立相位和频率,不再"齐刷刷"地一起闪。
|
|
202
|
+
const twinkle=linkTwinkle[linkIndex];
|
|
203
|
+
const breathe=twinkle ? (0.5+0.5*Math.sin(now*twinkle.freq+twinkle.phase))*twinkle.amp : 0;
|
|
204
|
+
// 度数补偿:枢纽节点周围几十条线会糊成一块光斑,
|
|
205
|
+
// 按两端度数把这类线整体压暗,让"一条线"和"一堆线"的观感接近。
|
|
206
|
+
const degSum=(first.star.degree||0)+(second.star.degree||0);
|
|
207
|
+
const damp=1/(1+0.07*Math.max(0,degSum-6));
|
|
208
|
+
const alpha=Math.min(.85,(.1*Math.min(first.star.retention,second.star.retention)+.12*act+breathe)*damp);
|
|
149
209
|
if (alpha<.01) continue;
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
210
|
+
// 端点沿法线错开,多条线进入同一节点时散成扇形
|
|
211
|
+
const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
|
|
212
|
+
const len=Math.hypot(dx,dy)||1;
|
|
213
|
+
const nx=-dy/len, ny=dx/len;
|
|
214
|
+
const r1=Math.max(2,first.star.radius*first.p.scale*.55);
|
|
215
|
+
const r2=Math.max(2,second.star.radius*second.p.scale*.55);
|
|
216
|
+
const sx=first.p.x+nx*r1*0.9*(twinkle?twinkle.jitterStart:0);
|
|
217
|
+
const sy=first.p.y+ny*r1*0.9*(twinkle?twinkle.jitterStart:0);
|
|
218
|
+
const ex=second.p.x+nx*r2*0.9*(twinkle?twinkle.jitterEnd:0);
|
|
219
|
+
const ey=second.p.y+ny*r2*0.9*(twinkle?twinkle.jitterEnd:0);
|
|
220
|
+
// 弯曲方向和弯度都按连线各自的哈希来,避免所有线朝同一侧弯成漩涡
|
|
221
|
+
const bow=twinkle?twinkle.bow:0.18;
|
|
222
|
+
const mx=(sx+ex)/2+nx*len*bow*0.5;
|
|
223
|
+
const my=(sy+ey)/2+ny*len*bow*0.5;
|
|
224
|
+
ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6+breathe*1.4;
|
|
225
|
+
ctx.beginPath();ctx.moveTo(sx,sy);ctx.quadraticCurveTo(mx,my,ex,ey);ctx.stroke();
|
|
154
226
|
}
|
|
155
227
|
for (let index=pulses.length-1;index>=0;index--) {
|
|
156
228
|
const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
|
|
@@ -166,8 +238,25 @@ function frame(now) {
|
|
|
166
238
|
for (const {star,p} of points) {
|
|
167
239
|
const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
|
|
168
240
|
const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
|
|
169
|
-
|
|
241
|
+
// 每颗星自己的呼吸频率和相位:从 id 哈希来,避免全图同一个节拍
|
|
242
|
+
if (star._pulse===undefined) {
|
|
243
|
+
const h=hashString(star.id);
|
|
244
|
+
star._pulse={freq:0.00018+(h%1000)/1000*0.00045,phase:(Math.floor(h/1000)%6283)/1000,amp:0.035+(Math.floor(h/7e6)%100)/100*0.045};
|
|
245
|
+
}
|
|
246
|
+
const pulse=star._pulse;
|
|
247
|
+
const breathe=Math.sin(now*pulse.freq+pulse.phase)*pulse.amp;
|
|
248
|
+
const radius=Math.max(2,star.radius*p.scale*.55)*(1+breathe)*(1+act*.5);
|
|
170
249
|
const rgb=star._rgb.join(',');
|
|
250
|
+
if (star.isCore) {
|
|
251
|
+
const halo=ctx.createRadialGradient(p.x,p.y,radius*.75,p.x,p.y,radius*2.2);
|
|
252
|
+
halo.addColorStop(0,'rgba('+rgb+',0.12)');
|
|
253
|
+
halo.addColorStop(.65,'rgba('+rgb+',0.04)');
|
|
254
|
+
halo.addColorStop(1,'rgba('+rgb+',0)');
|
|
255
|
+
ctx.fillStyle=halo;ctx.beginPath();ctx.arc(p.x,p.y,radius*2.2,0,Math.PI*2);ctx.fill();
|
|
256
|
+
ctx.strokeStyle='rgba('+rgb+',0.38)';ctx.lineWidth=.7;ctx.setLineDash([3,5]);ctx.lineDashOffset=-now*.008;
|
|
257
|
+
ctx.beginPath();ctx.arc(p.x,p.y,radius*1.6,0,Math.PI*2);ctx.stroke();
|
|
258
|
+
ctx.setLineDash([]);ctx.lineDashOffset=0;
|
|
259
|
+
}
|
|
171
260
|
ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
|
|
172
261
|
if (radius>2.5) for (const branch of star._dendrites) {
|
|
173
262
|
const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
|
|
@@ -202,7 +291,7 @@ function frame(now) {
|
|
|
202
291
|
}
|
|
203
292
|
function start() {
|
|
204
293
|
if (running || destroyed) return;
|
|
205
|
-
running=true; lastTime=0; nextFire=0;
|
|
294
|
+
running=true; lastTime=0; nextFire=0; autoSpin=true; resumeAt=0;
|
|
206
295
|
const currentGeneration = ++generation;
|
|
207
296
|
canvas.addEventListener('pointerdown',onPointerDown);
|
|
208
297
|
canvas.addEventListener('pointermove',onPointerMove);
|
package/lib/render/layout.mjs
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
const SEED = 20261030;
|
|
2
2
|
|
|
3
3
|
export function layoutMemories(nodes) {
|
|
4
|
+
if (!nodes.some(node => node.isCore)) return layoutBrain(nodes);
|
|
5
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
6
|
+
const inner = node => ['印象', '知识', '事件'].includes(node.kind);
|
|
7
|
+
const memories = nodes.filter(node => !node.isCore);
|
|
8
|
+
const shells = [memories.filter(inner), memories.filter(node => !inner(node))];
|
|
9
|
+
const placed = new Map();
|
|
10
|
+
function jitter(index) {
|
|
11
|
+
let hash = (index + 1) ^ SEED;
|
|
12
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
13
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
14
|
+
return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
|
|
15
|
+
}
|
|
16
|
+
shells.forEach((shell, layer) => {
|
|
17
|
+
shell.forEach((node, index) => {
|
|
18
|
+
const vertical = 1 - index / Math.max(1, shell.length - 1) * 2;
|
|
19
|
+
const radial = Math.sqrt(Math.max(0, 1 - vertical * vertical));
|
|
20
|
+
const angle = golden * (index + layer * 0.5);
|
|
21
|
+
const event = node.kind === '事件';
|
|
22
|
+
// 壳做厚,而不是贴在球面上:薄壳会把 236 个节点挤成一团。
|
|
23
|
+
// 内层 26~58(厚度 32),外层 70~108,节点沿半径方向均匀铺开,看起来才有立体感。
|
|
24
|
+
const layerMin = layer === 0 ? 26 : 70;
|
|
25
|
+
const layerMax = layer === 0 ? 58 : 108;
|
|
26
|
+
const age = layer === 1 ? Math.min(1, Math.max(0, node.ageDays / 30)) * 12 : 0;
|
|
27
|
+
// jitter 归一到 0~1,在 [layerMin, layerMax] 之间铺开
|
|
28
|
+
const spread = 0.5 + 0.5 * jitter(index + layer * memories.length);
|
|
29
|
+
// 情绪重的事件往内层靠一点,视觉上更"贴身"
|
|
30
|
+
const pull = event && node.majorEmotion ? 7 * spread : 0;
|
|
31
|
+
const distance = layerMin + (layerMax - layerMin) * spread - pull + age;
|
|
32
|
+
const baseRadius = node.majorEmotion ? 3.2 : layer === 0
|
|
33
|
+
? (node.heat === 1 ? 2.4 : node.heat === 2 ? 1.8 : 1.3)
|
|
34
|
+
: (node.heat === 1 ? 2.1 : node.heat === 2 ? 1.5 : 1.0);
|
|
35
|
+
placed.set(node, { ...node, pos: {
|
|
36
|
+
x: Math.cos(angle) * radial * distance,
|
|
37
|
+
y: vertical * distance * 0.92,
|
|
38
|
+
z: Math.sin(angle) * radial * distance,
|
|
39
|
+
}, radius: Math.min(layer === 0 ? 4.2 : 3.2, baseRadius + Math.min(1.1, Math.sqrt(node.degree) * 0.15)) });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
relax([...placed.values()], { iterations: 80, gap: 1.9, strength: 0.5 });
|
|
43
|
+
return nodes.map(node => node.isCore ? { ...node, pos: { x: 0, y: 0, z: 0 }, radius: 8 } : placed.get(node));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function layoutBrain(nodes) {
|
|
4
47
|
let seed = SEED;
|
|
5
48
|
const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
|
6
49
|
function brainPoint(radiusScale) {
|
package/lib/render/render.mjs
CHANGED
|
@@ -25,6 +25,7 @@ export function renderHtml(data, title = '记忆神经网络') {
|
|
|
25
25
|
.panel,.review{position:fixed;background:#07111fee;border:1px solid #3976a055;border-radius:14px;box-shadow:0 0 40px #1763a033;padding:15px;backdrop-filter:blur(8px)}
|
|
26
26
|
.panel{display:none;top:16px;right:18px;width:min(350px,calc(100vw - 36px));max-height:78vh;overflow:auto}
|
|
27
27
|
.panel h2{font-size:16px;color:#b7e9ff;margin:5px 0}.panel .meta{color:#8ba8c2;margin:7px 0}.panel .text{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
28
|
+
.panel.core .text{max-height:52vh;overflow:auto}.copy{margin-top:12px;padding:7px 12px;border:1px solid #6999b8;border-radius:8px;background:#102236;color:#e2f4ff;font:inherit;cursor:pointer}
|
|
28
29
|
.close{float:right;border:0;background:transparent;color:#a4bad0;font-size:18px;cursor:pointer}
|
|
29
30
|
.review{bottom:16px;left:18px;width:min(400px,calc(100vw - 36px));max-height:32vh;overflow:auto;font-size:12px}
|
|
30
31
|
.review h2{font-size:14px;color:#ffdb82;margin:0 0 8px}.row{display:flex;gap:7px;align-items:center;margin:5px 0;color:#b1c6d9}
|
|
@@ -36,7 +37,7 @@ export function renderHtml(data, title = '记忆神经网络') {
|
|
|
36
37
|
<body>
|
|
37
38
|
<canvas id="map" aria-label="记忆神经网络"></canvas>
|
|
38
39
|
<div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
|
|
39
|
-
<section class="panel" id="panel"><button class="close" id="close" aria-label="关闭">×</button><div id="kind"></div><h2 id="title"></h2><div class="meta" id="meta"></div><div class="text" id="body"></div></section>
|
|
40
|
+
<section class="panel" id="panel"><button class="close" id="close" aria-label="关闭">×</button><div id="kind"></div><h2 id="title"></h2><div class="meta" id="meta"></div><div class="text" id="body"></div><button class="copy" id="copy" hidden>复制全文</button></section>
|
|
40
41
|
<section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
|
|
41
42
|
<div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
|
|
42
43
|
<script>
|
|
@@ -56,10 +57,30 @@ for (const [index,item] of DATA.review.entries()) {
|
|
|
56
57
|
}
|
|
57
58
|
renderer.onSelect(node => {
|
|
58
59
|
document.getElementById('panel').style.display='block';
|
|
60
|
+
document.getElementById('panel').className=node.isCore?'panel core':'panel';
|
|
59
61
|
document.getElementById('kind').textContent=node.kind;
|
|
60
62
|
document.getElementById('title').textContent=node.title;
|
|
63
|
+
document.getElementById('meta').hidden=!!node.isCore;
|
|
61
64
|
document.getElementById('meta').textContent='重要度 '+node.heat+' · 留存 '+node.retention.toFixed(2)+' · 连接数 '+node.degree+(node.emotion.length?' · '+node.emotion.map(item=>item.tag).join('/'):'');
|
|
62
|
-
|
|
65
|
+
const text=node.isCore?Array.from(node.text).slice(0,4000).join(''):node.text;
|
|
66
|
+
document.getElementById('body').textContent=text;
|
|
67
|
+
document.getElementById('body').scrollTop=0;
|
|
68
|
+
const copy=document.getElementById('copy');
|
|
69
|
+
copy.hidden=!node.isCore; copy.textContent='复制全文';
|
|
70
|
+
const copyBody=async () => {
|
|
71
|
+
function fallback() {
|
|
72
|
+
const area=document.createElement('textarea');
|
|
73
|
+
area.value=text; area.style.cssText='position:fixed;left:-9999px'; document.body.append(area); area.select();
|
|
74
|
+
try { if (!document.execCommand('copy')) throw new Error('复制失败'); } finally { area.remove(); }
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
if (navigator.clipboard?.writeText) {
|
|
78
|
+
try { await navigator.clipboard.writeText(text); } catch { fallback(); }
|
|
79
|
+
} else fallback();
|
|
80
|
+
if (copy.onclick===copyBody) copy.textContent='已复制 ✓';
|
|
81
|
+
} catch { if (copy.onclick===copyBody) copy.textContent='复制失败,请重试'; }
|
|
82
|
+
};
|
|
83
|
+
copy.onclick=copyBody;
|
|
63
84
|
});
|
|
64
85
|
renderer.start();
|
|
65
86
|
</script>
|
package/lib/routes.js
CHANGED
|
@@ -3,21 +3,87 @@ import { computeMapData } from './map.js';
|
|
|
3
3
|
import { renderHtml } from './render/render.mjs';
|
|
4
4
|
import { ONBOARD_PROMPT, ONBOARD_STEPS } from './onboard.js';
|
|
5
5
|
import { readUiConfig } from './config.js';
|
|
6
|
+
import { chars, retention, titleOf } from './decay.js';
|
|
7
|
+
import { scoreMemories } from './render/score.mjs';
|
|
6
8
|
|
|
7
9
|
const base = '/memory-engine';
|
|
8
|
-
const paths = new Set([base, `${base}/`, `${base}/api/map`, `${base}/api/stats`, `${base}/api/onboard`, `${base}/api/config
|
|
10
|
+
const paths = new Set([base, `${base}/`, `${base}/api/map`, `${base}/api/stats`, `${base}/api/onboard`, `${base}/api/config`,
|
|
11
|
+
`${base}/api/list`, `${base}/api/export`, `${base}/api/clear`]);
|
|
9
12
|
const allowedMethods = new Set(['GET', 'POST', 'PUT', 'DELETE']);
|
|
10
13
|
const escapeBridge = '<script>window.addEventListener("keydown",function(event){if(event.key==="Escape")parent.postMessage({type:"dsh-memory-engine-close"},"*")});</script>';
|
|
11
14
|
|
|
15
|
+
function readBody(request) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
let chunks = [];
|
|
19
|
+
const finish = (error, value) => {
|
|
20
|
+
request.removeListener('data', onData);
|
|
21
|
+
request.removeListener('end', onEnd);
|
|
22
|
+
request.removeListener('error', onError);
|
|
23
|
+
request.removeListener('aborted', onAborted);
|
|
24
|
+
chunks = [];
|
|
25
|
+
if (error) reject(error); else resolve(value);
|
|
26
|
+
};
|
|
27
|
+
const onError = error => finish(error);
|
|
28
|
+
const onAborted = () => finish(Object.assign(new Error('请求已中断'), { status: 400 }));
|
|
29
|
+
const onData = chunk => {
|
|
30
|
+
bytes += chunk.length;
|
|
31
|
+
if (bytes > 1024 * 1024) {
|
|
32
|
+
finish(Object.assign(new Error('请求体不能超过 1MB'), { status: 413 }));
|
|
33
|
+
request.resume();
|
|
34
|
+
} else chunks.push(chunk);
|
|
35
|
+
};
|
|
36
|
+
const onEnd = () => {
|
|
37
|
+
let value;
|
|
38
|
+
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
39
|
+
catch { return finish(Object.assign(new Error('请求体必须是有效 JSON'), { status: 400 })); }
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return finish(Object.assign(new Error('请求体必须是 JSON 对象'), { status: 400 }));
|
|
41
|
+
finish(null, value);
|
|
42
|
+
};
|
|
43
|
+
request.on('data', onData);
|
|
44
|
+
request.on('end', onEnd);
|
|
45
|
+
request.on('error', onError);
|
|
46
|
+
request.on('aborted', onAborted);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function describeMemories(rows, now) {
|
|
51
|
+
const items = rows.map(row => ({ ...row, title: titleOf(row) }));
|
|
52
|
+
const degrees = new Map(scoreMemories(items, now).nodes.map(node => [node.id, node.degree]));
|
|
53
|
+
return items.map(row => ({ ...row, retention: retention(row, now), degree: degrees.get(row.id) }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function listMemories(store, query) {
|
|
57
|
+
const sort = query.get('sort') || 'recent';
|
|
58
|
+
const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
|
|
59
|
+
if (!['recent', 'oldest', 'fading', 'degree'].includes(sort)) invalid('sort 必须是 recent、oldest、fading 或 degree');
|
|
60
|
+
const limit = Number(query.get('limit') ?? 50);
|
|
61
|
+
const offset = Number(query.get('offset') ?? 0);
|
|
62
|
+
if (!Number.isSafeInteger(limit) || limit < 1) invalid('limit 必须是正整数');
|
|
63
|
+
if (!Number.isSafeInteger(offset) || offset < 0) invalid('offset 必须是非负整数');
|
|
64
|
+
const rows = store.read();
|
|
65
|
+
const search = (query.get('q') || '').toLowerCase();
|
|
66
|
+
const kind = query.get('kind') || '';
|
|
67
|
+
const items = describeMemories(rows, store.now()).filter(row => (!kind || row.kind === kind)
|
|
68
|
+
&& (!search || row.text.toLowerCase().includes(search) || row.title.toLowerCase().includes(search)));
|
|
69
|
+
const ordering = { recent: (left, right) => right.ts - left.ts, oldest: (left, right) => left.ts - right.ts,
|
|
70
|
+
fading: (left, right) => left.retention - right.retention, degree: (left, right) => right.degree - left.degree };
|
|
71
|
+
items.sort(ordering[sort]);
|
|
72
|
+
const size = Math.min(200, limit);
|
|
73
|
+
return { total: items.length, allTotal: rows.length, limit: size, offset,
|
|
74
|
+
items: items.slice(offset, offset + size).map(row => ({ ...row, preview: chars(row.text, 60) + (Array.from(row.text).length > 60 ? '…' : '') })) };
|
|
75
|
+
}
|
|
76
|
+
|
|
12
77
|
export function createMemoryHandler({ store, warn = () => {} }) {
|
|
13
78
|
return async (request, response) => {
|
|
79
|
+
let methods = ['GET'];
|
|
14
80
|
const reply = (status, payload, html = false) => {
|
|
15
81
|
response.writeHead(status, {
|
|
16
82
|
'Content-Type': html ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
|
|
17
83
|
'Cache-Control': 'no-store',
|
|
18
84
|
'X-Content-Type-Options': 'nosniff',
|
|
19
85
|
'Referrer-Policy': 'no-referrer',
|
|
20
|
-
...(status === 405 ? { Allow: '
|
|
86
|
+
...(status === 405 ? { Allow: methods.join(', ') } : {}),
|
|
21
87
|
...(html ? { 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" } : {}),
|
|
22
88
|
});
|
|
23
89
|
response.end(html ? payload : JSON.stringify(payload));
|
|
@@ -30,20 +96,39 @@ export function createMemoryHandler({ store, warn = () => {} }) {
|
|
|
30
96
|
let pathname;
|
|
31
97
|
try { pathname = decodeURIComponent(rawPath); }
|
|
32
98
|
catch { return fail(404, '找不到这个记忆接口'); }
|
|
33
|
-
|
|
34
|
-
if (
|
|
99
|
+
const item = /^\/memory-engine\/api\/item\/([^/%\\]+)$/u.exec(pathname);
|
|
100
|
+
if (pathname.includes('..') || pathname.includes('\\') || (!paths.has(pathname) && !item)) return fail(404, '找不到这个记忆接口');
|
|
101
|
+
methods = item ? ['GET', 'PUT', 'DELETE'] : pathname === `${base}/api/clear` ? ['POST'] : ['GET'];
|
|
102
|
+
if (!allowedMethods.has(request.method) || !methods.includes(request.method)) return fail(405, `此接口仅支持 ${methods.join('、')}`);
|
|
35
103
|
if (Number(request.headers['content-length']) > 1024 * 1024) return fail(413, '请求体不能超过 1MB');
|
|
36
|
-
if (request.
|
|
37
|
-
|
|
104
|
+
if (request.method === 'PUT' || request.method === 'POST') {
|
|
105
|
+
if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return fail(400, '请求体必须使用 application/json');
|
|
106
|
+
const body = await readBody(request);
|
|
107
|
+
return reply(200, { ok: true, data: item ? store.updateItem(item[1], body) : store.clear(body) });
|
|
108
|
+
}
|
|
109
|
+
if (request.headers['transfer-encoding'] || Number(request.headers['content-length']) > 0) return fail(400, request.method === 'GET' ? '此只读接口不接受请求体' : '此接口不接受请求体');
|
|
110
|
+
if (item) {
|
|
111
|
+
if (request.method === 'DELETE') return reply(200, { ok: true, data: store.deleteItem(item[1]) });
|
|
112
|
+
const rows = store.read();
|
|
113
|
+
if (!rows.some(row => row.id === item[1])) return fail(404, '没有这条记忆');
|
|
114
|
+
return reply(200, { ok: true, data: describeMemories(rows, store.now()).find(row => row.id === item[1]) });
|
|
115
|
+
}
|
|
116
|
+
if (pathname === `${base}/api/list`) return reply(200, { ok: true, data: listMemories(store, new URLSearchParams(request.url.includes('?') ? request.url.slice(request.url.indexOf('?') + 1) : '')) });
|
|
117
|
+
if (pathname === `${base}/api/export`) {
|
|
118
|
+
const items = store.read();
|
|
119
|
+
return reply(200, { ok: true, data: { total: items.length, items } });
|
|
120
|
+
}
|
|
121
|
+
if (pathname === `${base}/api/map`) return reply(200, { ok: true, data: computeMapData(store, { warn }) });
|
|
38
122
|
if (pathname === `${base}/api/stats`) return reply(200, { ok: true, data: store.stats() });
|
|
39
|
-
if (pathname === `${base}/api/config`) return reply(200, { ok: true, data: readUiConfig(path.dirname(path.dirname(store.file))) });
|
|
123
|
+
if (pathname === `${base}/api/config`) return reply(200, { ok: true, data: readUiConfig(path.dirname(path.dirname(store.file)), { warn }) });
|
|
40
124
|
if (pathname === `${base}/api/onboard`) return reply(200, { ok: true, data: {
|
|
41
125
|
empty: store.read().length === 0, prompt: ONBOARD_PROMPT, steps: ONBOARD_STEPS, dataDir: path.dirname(store.file),
|
|
42
126
|
} });
|
|
43
|
-
const { nodes, ...data } = computeMapData(store);
|
|
127
|
+
const { nodes, ...data } = computeMapData(store, { warn });
|
|
44
128
|
const html = renderHtml({ ...data, stars: nodes }, '我的记忆神经网络');
|
|
45
129
|
return reply(200, html.replace('</body>', escapeBridge + '</body>'), true);
|
|
46
130
|
} catch (error) {
|
|
131
|
+
if ([400, 404, 413].includes(error.status)) return fail(error.status, error.message);
|
|
47
132
|
warn(`图谱请求失败:${error.message}`);
|
|
48
133
|
return fail(500, '读取记忆失败,请检查本地插件日志');
|
|
49
134
|
}
|
package/lib/store.js
CHANGED
|
@@ -83,13 +83,58 @@ export class MemoryStore {
|
|
|
83
83
|
backup() {
|
|
84
84
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
85
85
|
const base = `${this.file}.bak-${Date.now()}`;
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const directory = path.dirname(this.file);
|
|
87
|
+
const prefix = path.basename(base) + '-';
|
|
88
|
+
const suffixes = fs.readdirSync(directory).filter(name => name.startsWith(prefix))
|
|
89
|
+
.map(name => Number(name.slice(prefix.length))).filter(Number.isSafeInteger);
|
|
90
|
+
let suffix = suffixes.length ? Math.max(...suffixes) + 1 : 0;
|
|
91
|
+
let file = suffix ? `${base}-${suffix}` : base;
|
|
92
|
+
while (fs.existsSync(file)) file = `${base}-${++suffix}`;
|
|
88
93
|
if (fs.existsSync(this.file)) fs.copyFileSync(this.file, file, fs.constants.COPYFILE_EXCL);
|
|
89
94
|
else fs.writeFileSync(file, '[]\n', { flag: 'wx', mode: 0o600 });
|
|
95
|
+
const backups = fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
96
|
+
const match = /^memories\.json\.bak-(\d+)(?:-(\d+))?$/u.exec(entry.name);
|
|
97
|
+
return entry.isFile() && match ? [{ name: entry.name, time: Number(match[1]), suffix: Number(match[2] ?? 0) }] : [];
|
|
98
|
+
}).sort((left, right) => right.time - left.time || right.suffix - left.suffix);
|
|
99
|
+
for (const old of backups.slice(5)) fs.unlinkSync(path.join(directory, old.name));
|
|
90
100
|
return file;
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
updateItem(id, changes) {
|
|
104
|
+
const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
|
|
105
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) invalid('请求体必须是 JSON 对象');
|
|
106
|
+
for (const key of Object.keys(changes)) {
|
|
107
|
+
if (!['text', 'kind', 'heat', 'who'].includes(key)) invalid(`不支持修改 ${key}`);
|
|
108
|
+
}
|
|
109
|
+
if (Object.hasOwn(changes, 'text') && (!nonempty(changes.text) || Array.from(changes.text).length > 2000)) invalid('text 必须是 1~2000 字符,且不能全为空白');
|
|
110
|
+
if (Object.hasOwn(changes, 'kind') && (!nonempty(changes.kind) || Array.from(changes.kind).length > 20)) invalid('kind 必须是 1~20 字符');
|
|
111
|
+
if (Object.hasOwn(changes, 'heat') && ![1, 2, 3].includes(changes.heat)) invalid('heat 必须是整数 1、2 或 3');
|
|
112
|
+
if (Object.hasOwn(changes, 'who') && (typeof changes.who !== 'string' || Array.from(changes.who).length > 40)) invalid('who 必须是 0~40 字符');
|
|
113
|
+
const rows = this.read();
|
|
114
|
+
const index = rows.findIndex(row => row.id === id);
|
|
115
|
+
if (index === -1) throw Object.assign(new Error('没有这条记忆'), { status: 404 });
|
|
116
|
+
rows[index] = { ...rows[index], ...changes };
|
|
117
|
+
const backup = this.backup();
|
|
118
|
+
this.write(rows);
|
|
119
|
+
return { ...rows[index], backup };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
deleteItem(id) {
|
|
123
|
+
const rows = this.read();
|
|
124
|
+
if (!rows.some(row => row.id === id)) throw Object.assign(new Error('没有这条记忆'), { status: 404 });
|
|
125
|
+
const backup = this.backup();
|
|
126
|
+
this.write(rows.filter(row => row.id !== id));
|
|
127
|
+
return { deleted: id, backup };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
clear({ confirm } = {}) {
|
|
131
|
+
if (confirm !== 'DELETE') throw Object.assign(new Error('请输入大写 DELETE 确认清空'), { status: 400 });
|
|
132
|
+
const rows = this.read();
|
|
133
|
+
const backup = this.backup();
|
|
134
|
+
this.write([]);
|
|
135
|
+
return { removed: rows.length, backup };
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
dedupe({ threshold = 0.7, dry_run = true } = {}) {
|
|
94
139
|
if (typeof dry_run !== 'boolean') throw new Error('dry_run 必须是布尔值');
|
|
95
140
|
const started = performance.now();
|