dsh-memory-engine 0.7.6 → 0.8.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.
@@ -4,8 +4,43 @@ export function createRenderer(canvas, data, options = {}) {
4
4
  const performance = window.performance;
5
5
  const ctx = canvas.getContext('2d', { alpha: false });
6
6
  if (!ctx) throw new Error('Canvas 2D 不可用');
7
- const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
8
- const fallback = [145,205,255];
7
+ const THEMES = {
8
+ classic: {
9
+ colors: { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] },
10
+ fallback:[145,205,255], background:'#03060c', grid:'rgba(40,90,140,0.055)',
11
+ link:'rgba(110,190,255,0.30)', linkHighlight:'rgba(160,220,255,0.22)',
12
+ pulse:[205,245,255], center:[255,255,255], fading:[255,110,110], glow:[0.7,0.19],
13
+ vignette:['rgba(0,0,0,0)','rgba(0,0,0,.78)'],
14
+ },
15
+ neon: {
16
+ colors: { '人格':[255,255,255], '印象':[63,242,224], '知识':[255,95,210], '事件':[165,107,255], '日记':[192,125,255], '对话':[255,147,233], '情绪':[255,66,130] },
17
+ fallback:[145,143,255], background:'#0a0410', grid:'rgba(156,70,210,0.07)',
18
+ link:['rgba(255,95,210,0.36)','rgba(165,107,255,0.36)'], linkHighlight:'rgba(224,175,255,0.24)',
19
+ pulse:[245,210,255], center:[255,255,255], fading:[255,90,165], glow:[0.7,0.19],
20
+ vignette:['rgba(10,4,16,0)','rgba(10,4,16,.78)'],
21
+ },
22
+ paper: {
23
+ colors: { '人格':[40,40,40], '印象':[107,107,107], '知识':[64,64,64], '事件':[85,85,85], '日记':[133,133,133], '对话':[116,116,116], '情绪':[76,76,76] },
24
+ fallback:[96,96,96], background:'#f7f5f0', grid:'rgba(80,78,73,0.022)',
25
+ link:'rgba(65,65,65,0.34)', linkHighlight:'rgba(100,100,100,0.055)',
26
+ pulse:[48,48,48], center:[36,36,36], fading:[126,126,126], glow:[0.025,0.006],
27
+ vignette:['rgba(135,132,125,0)','rgba(135,132,125,.08)'],
28
+ },
29
+ mono: {
30
+ colors: { '人格':[232,232,232], '印象':[200,200,200], '知识':[255,255,255], '事件':[138,138,138], '日记':[174,174,174], '对话':[218,218,218], '情绪':[160,160,160] },
31
+ fallback:[185,185,185], background:'#0d0d0d', grid:'rgba(130,130,130,0.045)',
32
+ link:'rgba(178,178,178,0.30)', linkHighlight:'rgba(210,210,210,0.22)',
33
+ pulse:[235,235,235], center:[255,255,255], fading:[150,150,150], glow:[0.7,0.19],
34
+ vignette:['rgba(0,0,0,0)','rgba(0,0,0,.78)'],
35
+ },
36
+ };
37
+ let themeName = options.graphTheme ?? data.graphTheme ?? 'classic';
38
+ if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
39
+ let theme = THEMES[themeName];
40
+ let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
41
+ if (!['sphere', 'flat', 'spiral'].includes(layoutName)) throw new Error('未知图谱布局');
42
+ let sphereNodes = [];
43
+ const layouts = new Map();
9
44
  let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
10
45
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
11
46
  const pulses = [], firing = new Map(), selections = new Set();
@@ -24,13 +59,44 @@ function glowSprite(rgb) {
24
59
  off.width = off.height = 96;
25
60
  const brush = off.getContext('2d');
26
61
  const gradient = brush.createRadialGradient(48,48,1,48,48,48);
27
- gradient.addColorStop(0,'rgba('+rgb.join(',')+',0.7)');
28
- gradient.addColorStop(0.4,'rgba('+rgb.join(',')+',0.19)');
62
+ gradient.addColorStop(0,'rgba('+rgb.join(',')+','+theme.glow[0]+')');
63
+ gradient.addColorStop(0.4,'rgba('+rgb.join(',')+','+theme.glow[1]+')');
29
64
  gradient.addColorStop(1,'rgba('+rgb.join(',')+',0)');
30
65
  brush.fillStyle = gradient; brush.fillRect(0,0,96,96);
31
66
  return off;
32
67
  }
33
68
 
69
+ function recolor() {
70
+ sprites.clear();
71
+ for (const star of nodes) {
72
+ star._rgb = theme.colors[star.kind] || theme.fallback;
73
+ const key = star._rgb.join(',');
74
+ if (!sprites.has(key)) sprites.set(key, glowSprite(star._rgb));
75
+ star._glow = sprites.get(key);
76
+ }
77
+ }
78
+
79
+ function setTheme(nextTheme) {
80
+ if (destroyed || nextTheme === themeName) return;
81
+ if (!Object.hasOwn(THEMES, nextTheme)) throw new Error('未知图谱配色');
82
+ themeName = nextTheme;
83
+ theme = THEMES[nextTheme];
84
+ recolor();
85
+ }
86
+
87
+ function setLayout(nextLayout) {
88
+ if (destroyed) return;
89
+ if (!['sphere', 'flat', 'spiral'].includes(nextLayout)) throw new Error('未知图谱布局');
90
+ if (layoutName === nextLayout && layouts.has(nextLayout)) return;
91
+ if (!layouts.has(nextLayout)) {
92
+ if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
93
+ layouts.set(nextLayout, options.layoutNodes(sphereNodes, nextLayout).map(node => ({ ...node.pos })));
94
+ }
95
+ const positions = layouts.get(nextLayout);
96
+ nodes.forEach((node, index) => { node.pos = { ...positions[index] }; });
97
+ layoutName = nextLayout;
98
+ }
99
+
34
100
  // 字符串 → 32 位无符号哈希。给连线的闪烁参数做种子:
35
101
  // 用 id 而不是 Math.random,保证同一条线在每次打开时节奏一致(不会每次刷新都跳),
36
102
  // 但不同连线之间互不同步。
@@ -47,6 +113,10 @@ function glowSprite(rgb) {
47
113
  if (destroyed) return;
48
114
  spinSpeed=nextData.spinSpeed??0.35;
49
115
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
116
+ sphereNodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
117
+ layouts.clear();
118
+ layouts.set('sphere', sphereNodes.map(node => ({ ...node.pos })));
119
+ if (layoutName !== 'sphere') setLayout(layoutName);
50
120
  links = nextData.links.map(link => [...link]);
51
121
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
52
122
  selectedId=typeof options.selectedId==='string'?options.selectedId:null;
@@ -94,7 +164,6 @@ function glowSprite(rgb) {
94
164
  .slice(0, LINK_DRAW_CAP);
95
165
  }
96
166
  for (const star of nodes) {
97
- star._rgb = colors[star.kind] || fallback;
98
167
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
99
168
  star._dendrites = Array.from({length:count},(_,index) => ({
100
169
  angle:index/count*Math.PI*2+random()*0.7,
@@ -102,11 +171,7 @@ for (const star of nodes) {
102
171
  fork:random()<0.55 ? (random()-0.5)*1.5 : null
103
172
  }));
104
173
  }
105
- for (const star of nodes) {
106
- const key = star._rgb.join(',');
107
- if (!sprites.has(key)) sprites.set(key,glowSprite(star._rgb));
108
- star._glow = sprites.get(key);
109
- }
174
+ recolor();
110
175
 
111
176
  }
112
177
 
@@ -210,8 +275,8 @@ function frame(now) {
210
275
  }
211
276
  nextFire=now+1400+Math.random()*3200;
212
277
  }
213
- ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
214
- ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
278
+ ctx.globalCompositeOperation='source-over'; ctx.fillStyle=theme.background; ctx.fillRect(0,0,width,height);
279
+ ctx.strokeStyle=theme.grid;ctx.lineWidth=1;
215
280
  for (let index=0;index<20;index++) {
216
281
  const y=index/20*height;
217
282
  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();
@@ -246,13 +311,18 @@ function frame(now) {
246
311
  }
247
312
  }
248
313
  if (linkPaths.length) {
249
- ctx.strokeStyle='rgba(110,190,255,0.30)';
314
+ if (Array.isArray(theme.link)) {
315
+ const gradient = ctx.createLinearGradient(0,0,width,height);
316
+ gradient.addColorStop(0,theme.link[0]);
317
+ gradient.addColorStop(1,theme.link[1]);
318
+ ctx.strokeStyle=gradient;
319
+ } else ctx.strokeStyle=theme.link;
250
320
  ctx.lineWidth=1;
251
321
  ctx.beginPath();
252
322
  for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
253
323
  ctx.stroke();
254
324
  // 更亮的一层,让关联线看得清
255
- ctx.strokeStyle='rgba(160,220,255,0.22)';
325
+ ctx.strokeStyle=theme.linkHighlight;
256
326
  ctx.lineWidth=2;
257
327
  ctx.stroke();
258
328
  }
@@ -265,7 +335,7 @@ function frame(now) {
265
335
  const inverse=1-fraction;
266
336
  const x=inverse*inverse*first.p.x+2*inverse*fraction*mx+fraction*fraction*second.p.x;
267
337
  const y=inverse*inverse*first.p.y+2*inverse*fraction*my+fraction*fraction*second.p.y;
268
- ctx.fillStyle='rgba(205,245,255,'+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
338
+ ctx.fillStyle='rgba('+theme.pulse.join(',')+','+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
269
339
  }
270
340
  for (const {star,p} of points) {
271
341
  const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
@@ -305,9 +375,9 @@ function frame(now) {
305
375
  ctx.globalAlpha=Math.min(1,.4+intensity*.7);ctx.drawImage(star._glow,p.x-glowRadius,p.y-glowRadius,glowRadius*2,glowRadius*2);ctx.globalAlpha=1;
306
376
  ctx.fillStyle='rgba('+rgb+','+Math.min(1,.42*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
307
377
  ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.75*intensity)+')';ctx.lineWidth=1;ctx.stroke();
308
- ctx.fillStyle='rgba(255,255,255,'+Math.min(1,.6*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius*.42,0,Math.PI*2);ctx.fill();
378
+ ctx.fillStyle='rgba('+theme.center.join(',')+','+Math.min(1,.6*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius*.42,0,Math.PI*2);ctx.fill();
309
379
  if (star.retention<.25) {
310
- ctx.strokeStyle='rgba(255,110,110,'+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
380
+ ctx.strokeStyle='rgba('+theme.fading.join(',')+','+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
311
381
  ctx.beginPath();ctx.arc(p.x,p.y,radius*3.2,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
312
382
  }
313
383
  star._screen={x:p.x,y:p.y,r:Math.max(8,radius*2.6)};
@@ -318,7 +388,7 @@ function frame(now) {
318
388
  }
319
389
  ctx.globalCompositeOperation='source-over';
320
390
  const vignette=ctx.createRadialGradient(width/2,height/2,Math.min(width,height)*.34,width/2,height/2,Math.max(width,height)*.8);
321
- vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.78)');
391
+ vignette.addColorStop(0,theme.vignette[0]);vignette.addColorStop(1,theme.vignette[1]);
322
392
  ctx.fillStyle=vignette;ctx.fillRect(0,0,width,height);
323
393
  }
324
394
  function start() {
@@ -371,12 +441,12 @@ function frame(now) {
371
441
  if (destroyed) return;
372
442
  stop(); destroyed=true;
373
443
  selections.clear(); byId.clear(); adjacency.clear(); sprites.clear();
374
- nodes=[]; links=[]; hubs=[];
444
+ nodes=[]; links=[]; hubs=[]; sphereNodes=[]; layouts.clear();
375
445
  }
376
446
 
377
447
  setData(data);
378
448
  return {
379
- start, stop, resize, setData, destroy,
449
+ start, stop, resize, setData, setTheme, setLayout, destroy,
380
450
  fireNeuron(id) { if (!destroyed && byId.has(id)) fire(id,performance.now()); },
381
451
  onSelect(callback) {
382
452
  if (!destroyed) selections.add(callback);
@@ -1,5 +1,38 @@
1
1
  const SEED = 20261030;
2
2
 
3
+ export function layoutPositions(nodes, layout = 'sphere') {
4
+ if (!['sphere', 'flat', 'spiral'].includes(layout)) throw new Error('未知图谱布局');
5
+ if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
6
+ const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
7
+ (right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
8
+ const positions = new Map();
9
+ function jitter(id, axis) {
10
+ let hash = 20261030;
11
+ for (const character of String(id) + ':' + axis) hash = Math.imul(hash ^ character.codePointAt(0), 16777619);
12
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
13
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
14
+ }
15
+ if (layout === 'flat') {
16
+ for (let ring = 0; ring < 3; ring++) {
17
+ const members = memories.slice(Math.ceil(memories.length * ring / 3), Math.ceil(memories.length * (ring + 1) / 3));
18
+ const radius = [30, 55, 80][ring];
19
+ members.forEach((node, index) => {
20
+ const angle = index / members.length * Math.PI * 2;
21
+ positions.set(node.id, { x: Math.cos(angle) * radius + jitter(node.id, 'x') * 3,
22
+ y: Math.sin(angle) * radius + jitter(node.id, 'y') * 3, z: 0 });
23
+ });
24
+ }
25
+ } else {
26
+ memories.forEach((node, index) => {
27
+ const fraction = index / Math.max(1, memories.length - 1);
28
+ const angle = fraction * 3.2 * Math.PI + index % 3 * Math.PI * 2 / 3 + jitter(node.id, 'angle') * 0.35;
29
+ const radius = 18 + fraction * 92;
30
+ positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: jitter(node.id, 'z') * 14 });
31
+ });
32
+ }
33
+ return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
34
+ }
35
+
3
36
  export function layoutMemories(nodes) {
4
37
  if (!nodes.some(node => node.isCore)) return layoutBrain(nodes);
5
38
  const golden = Math.PI * (3 - Math.sqrt(5));
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { stripRendererExport } from '../../scripts/lib/strip-export.mjs';
3
+ import { layoutPositions } from './layout.mjs';
3
4
 
4
5
  const rendererSource = stripRendererExport(fs.readFileSync(new URL('./canvas.js', import.meta.url), 'utf8'));
5
6
 
@@ -31,10 +32,21 @@ export function renderHtml(data, title = '记忆神经网络') {
31
32
  .review h2{font-size:14px;color:#ffdb82;margin:0 0 8px}.row{display:flex;gap:7px;align-items:center;margin:5px 0;color:#b1c6d9}
32
33
  .row .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar{width:52px;height:5px;border-radius:5px;background:#335066;overflow:hidden}.bar i{display:block;height:100%;background:#6bc9fa}
33
34
  .tip{position:fixed;right:18px;bottom:18px;color:#7b99ad;pointer-events:none}
35
+ body[data-graph-theme=neon]{--text:#f4dfff;--muted:#ba9aca;--accent:#ff9de1;--border:#704580;--panel:#170d21ee;--button:#271333}
36
+ body[data-graph-theme=paper]{color-scheme:light;--text:#303030;--muted:#72716d;--accent:#404040;--border:#b5b1a9;--panel:#f7f5f0f2;--button:#eeece6}
37
+ body[data-graph-theme=mono]{--text:#eee;--muted:#aaa;--accent:#eee;--border:#666;--panel:#171717ee;--button:#252525}
38
+ body:not([data-graph-theme=classic]){color:var(--text)}
39
+ body:not([data-graph-theme=classic]) .hud{color:var(--text);text-shadow:none}
40
+ body:not([data-graph-theme=classic]) .hud strong,body:not([data-graph-theme=classic]) h2{color:var(--accent)}
41
+ body:not([data-graph-theme=classic]) .sub,body:not([data-graph-theme=classic]) .tip,body:not([data-graph-theme=classic]) .meta{color:var(--muted)}
42
+ body:not([data-graph-theme=classic]) .panel,body:not([data-graph-theme=classic]) .review{background:var(--panel);border-color:var(--border);box-shadow:none}
43
+ body:not([data-graph-theme=classic]) .row,body:not([data-graph-theme=classic]) .close{color:var(--text)}
44
+ body:not([data-graph-theme=classic]) .copy{color:var(--text);background:var(--button);border-color:var(--border)}
45
+ body:not([data-graph-theme=classic]) .bar{background:var(--border)}body:not([data-graph-theme=classic]) .bar i{background:var(--accent)}
34
46
  @media(max-width:700px){.hud{top:8px;left:10px}.hud .sub,.tip{display:none}.review{left:10px;bottom:10px;max-height:24vh}.panel{top:8px;right:8px;max-height:55vh}}
35
47
  </style>
36
48
  </head>
37
- <body>
49
+ <body data-graph-theme="${escapeHtml(data.graphTheme || 'classic')}">
38
50
  <canvas id="map" aria-label="记忆神经网络"></canvas>
39
51
  <div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
40
52
  <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>
@@ -44,7 +56,8 @@ export function renderHtml(data, title = '记忆神经网络') {
44
56
  const DATA = ${embedded};
45
57
  const canvas = document.getElementById('map');
46
58
  ${rendererSource}
47
- const renderer = createRenderer(canvas, { ...DATA, nodes: DATA.stars });
59
+ ${layoutPositions.toString()}
60
+ const renderer = createRenderer(canvas, { ...DATA, nodes: DATA.stars }, { layoutNodes: layoutPositions });
48
61
  document.getElementById('close').onclick=() => { document.getElementById('panel').style.display='none'; };
49
62
  document.getElementById('total').textContent=DATA.stats.total;
50
63
  document.getElementById('linkCount').textContent=DATA.links.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-engine",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "description": "DSH 本地记忆插件:自动记忆、遗忘曲线、关键词关联、可检索可编辑,数据留在本机。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -1,13 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { stripRendererExport } from './lib/strip-export.mjs';
4
+ import { layoutPositions } from '../lib/render/layout.mjs';
4
5
 
5
6
  const template = fs.readFileSync(new URL('../lib/client.src.js', import.meta.url), 'utf8');
6
7
  const renderer = fs.readFileSync(new URL('../lib/render/canvas.js', import.meta.url), 'utf8');
7
8
  const marker = '/* __CANVAS_RENDERER__ */';
8
9
  if (template.split(marker).length !== 2) throw new Error('Expected exactly one renderer marker');
10
+ const layoutMarker = '/* __GRAPH_LAYOUT__ */';
11
+ if (template.split(layoutMarker).length !== 2) throw new Error('Expected exactly one layout marker');
9
12
  const bundled = '// 由 scripts/build-client.mjs 生成,别直接改这个文件\n'
10
- + template.replace(marker, () => stripRendererExport(renderer));
13
+ + template.replace(marker, () => stripRendererExport(renderer)).replace(layoutMarker, () => layoutPositions.toString());
11
14
  const target = new URL('../lib/client.js', import.meta.url);
12
15
  if (process.argv.includes('--check')) {
13
16
  if (fs.readFileSync(target, 'utf8') !== bundled) throw new Error('lib/client.js 已过期,请跑 pnpm build');