dsh-memory-engine 0.7.7 → 0.8.1

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # DSH 记忆引擎
2
2
 
3
- [![npm](https://img.shields.io/badge/version-0.8.0-blue)](https://www.npmjs.com/package/dsh-memory-engine)
3
+ [![npm](https://img.shields.io/badge/version-0.8.1-blue)](https://www.npmjs.com/package/dsh-memory-engine)
4
4
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
- [![tests](https://img.shields.io/badge/tests-277%20passed-brightgreen)](test)
5
+ [![tests](https://img.shields.io/badge/tests-291%20passed-brightgreen)](test)
6
6
 
7
7
  DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
8
8
 
@@ -11,21 +11,48 @@ DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存
11
11
 
12
12
  ![记忆图谱](docs/screenshot.png)
13
13
 
14
- 当前版本:0.8.0。Node ≥22,零新增依赖。新增四套配色与三种布局,默认保留原来的经典星空与球体。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收聊天会话的用户消息,支持来源白名单、工作指令过滤、流程话过滤、规则分类、错误原因、一次性重扫和账本清理。
14
+ 当前版本:0.8.1。Node ≥22,零新增依赖。四套配色与球体、星系、大脑神经网络三种布局自由组合,默认保留原来的经典星空与球体。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收聊天会话的用户消息,支持来源白名单、工作指令过滤、流程话过滤、规则分类、错误原因、一次性重扫和账本清理。
15
15
 
16
- ## 0.8.0 图谱外观
16
+ ## 安装(重要)
17
+
18
+ **请指定版本号安装。** pnpm 可能因为本地缓存的包元数据解析到旧版本(实测 `pnpm add dsh-memory-engine` 会装到 0.7.5):
19
+
20
+ ```bash
21
+ pnpm add dsh-memory-engine@0.8.1
22
+ ```
23
+
24
+ 装完重启 DSH,标题栏出现「🧠 记忆图谱」。
25
+
26
+ 0.7.5 及更早版本存在 `memory_map` 参数名撞 JSON Schema 保留字的问题,调用时整轮返回 400,请勿使用。
27
+
28
+ ## 0.8.1 图谱外观
17
29
 
18
30
  图谱头部点击「外观」,可组合选择四套配色与三种布局;「设置」页也有同一组选项。点选立即应用并保存,刷新后保留;保存失败会回滚并显示原因。「恢复默认」同时恢复经典星空和球体。
19
31
 
20
32
  | 维度 | 选项 |
21
33
  | --- | --- |
22
34
  | 配色 | 经典星空 `classic` / 赛博霓虹 `neon` / 铅绘素描 `paper` / 极简单色 `mono` |
23
- | 布局 | 球体 `sphere` / 平面圆 `flat` / 螺旋银河 `spiral` |
35
+ | 布局 | 球体 `sphere` / 星系 `galaxy` / 大脑神经网络 `brain` |
36
+
37
+ 铅绘素描采用素纸底色、石墨灰节点和极淡光晕。星系有密集核心、2~4 条带抖动的旋臂和 30% 星尘;大脑神经网络有不对称的左右半球、每半球 12~14 个簇心、5% 游离节点和表面起伏。固定种子与节点 id 哈希保证相同数据的位置完全一致。三种布局都保留中心区域角色卡、旋转、缩放和点击详情;大脑角色卡位于纵裂中间偏下。切换时保留已选节点、相机及动画状态,不重新请求记忆数据,不改变记忆的重要度、留存或节点尺寸算法。星系仅将选中关联线的透明度和宽度降到球体的 80%,避免密集连线发白。
24
38
 
25
- 铅绘素描采用素纸底色、石墨灰节点和极淡光晕。平面圆按连接数分为三个同心环;螺旋银河沿三条旋臂排列。三种布局都保留角色卡中心、旋转、缩放和点击详情。切换时保留已选节点、相机及动画状态,不重新请求记忆数据,不改变记忆的重要度、留存或节点尺寸算法。
39
+ 0.8.0 的旧配置自动兼容:`flat` 按 `galaxy` 读取,`spiral` 按 `brain` 读取,不需要手动改文件,也不在读取时写盘。旧客户端提交这两个旧值也会被接受,并通过原有备份和原子写保存为新值。界面只显示球体、星系、大脑神经网络三种选项。
26
40
 
27
41
  在现有配置中可合并 `"graphTheme": "classic"` 和 `"graphLayout": "sphere"`。这两个字段通过现有配置接口和备份、原子写流程保存,非法值返回 400。未配置时外观与升级前相同;没有角色卡的库仍保留原来的默认脑状布局。CLI 生成的离线图谱也读取同一配置。
28
42
 
43
+ ## 0.7.7 修复:工具参数名撞 JSON Schema 保留字
44
+
45
+ `memory_map` 的 `title`、`memory_forget` 的 `id` 是 JSON Schema 的保留关键字。宿主如果没把参数表转换成完整 schema,直接把这些名字当关键字发送,DeepSeek 会整轮返回 400:
46
+
47
+ ```
48
+ Invalid schema for function 'memory_map':
49
+ {"type":"string","description":"页面标题..."} is not of type "string"
50
+ ```
51
+
52
+ 修复:改名为 `pageTitle` / `memoryId`,旧参数名仍作为别名兼容,已有调用不受影响。
53
+
54
+ **给插件作者**:工具参数名不要用 JSON Schema 关键字——`title`、`description`、`type`、`id`、`$ref`、`enum`、`default`、`required`、`properties`、`items`、`additionalProperties` 等。宿主把参数表当 schema 解析时会直接报错。
55
+
29
56
  ## 从 0.6.1 升级到 0.7.0 的变化
30
57
 
31
58
  - 不再占用 18886 端口,改用 DSH 宿主的同源路由。
package/lib/client.js CHANGED
@@ -54,7 +54,9 @@ window.__ModuleLoader__.load({
54
54
  if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
55
55
  let theme = THEMES[themeName];
56
56
  let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
57
- if (!['sphere', 'flat', 'spiral'].includes(layoutName)) throw new Error('未知图谱布局');
57
+ if (layoutName === 'flat') layoutName = 'galaxy';
58
+ if (layoutName === 'spiral') layoutName = 'brain';
59
+ if (!['sphere', 'galaxy', 'brain'].includes(layoutName)) throw new Error('未知图谱布局');
58
60
  let sphereNodes = [];
59
61
  const layouts = new Map();
60
62
  let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
@@ -102,7 +104,9 @@ function glowSprite(rgb) {
102
104
 
103
105
  function setLayout(nextLayout) {
104
106
  if (destroyed) return;
105
- if (!['sphere', 'flat', 'spiral'].includes(nextLayout)) throw new Error('未知图谱布局');
107
+ if (nextLayout === 'flat') nextLayout = 'galaxy';
108
+ if (nextLayout === 'spiral') nextLayout = 'brain';
109
+ if (!['sphere', 'galaxy', 'brain'].includes(nextLayout)) throw new Error('未知图谱布局');
106
110
  if (layoutName === nextLayout && layouts.has(nextLayout)) return;
107
111
  if (!layouts.has(nextLayout)) {
108
112
  if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
@@ -327,20 +331,22 @@ function frame(now) {
327
331
  }
328
332
  }
329
333
  if (linkPaths.length) {
334
+ if (layoutName === 'galaxy') ctx.globalAlpha=0.8;
330
335
  if (Array.isArray(theme.link)) {
331
336
  const gradient = ctx.createLinearGradient(0,0,width,height);
332
337
  gradient.addColorStop(0,theme.link[0]);
333
338
  gradient.addColorStop(1,theme.link[1]);
334
339
  ctx.strokeStyle=gradient;
335
340
  } else ctx.strokeStyle=theme.link;
336
- ctx.lineWidth=1;
341
+ ctx.lineWidth=layoutName === 'galaxy' ? 0.8 : 1;
337
342
  ctx.beginPath();
338
343
  for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
339
344
  ctx.stroke();
340
345
  // 更亮的一层,让关联线看得清
341
346
  ctx.strokeStyle=theme.linkHighlight;
342
- ctx.lineWidth=2;
347
+ ctx.lineWidth=layoutName === 'galaxy' ? 1.6 : 2;
343
348
  ctx.stroke();
349
+ if (layoutName === 'galaxy') ctx.globalAlpha=1;
344
350
  }
345
351
  for (let index=pulses.length-1;index>=0;index--) {
346
352
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -473,7 +479,9 @@ function frame(now) {
473
479
 
474
480
 
475
481
  function layoutPositions(nodes, layout = 'sphere') {
476
- if (!['sphere', 'flat', 'spiral'].includes(layout)) throw new Error('未知图谱布局');
482
+ if (layout === 'flat') layout = 'galaxy';
483
+ if (layout === 'spiral') layout = 'brain';
484
+ if (!['sphere', 'galaxy', 'brain'].includes(layout)) throw new Error('未知图谱布局');
477
485
  if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
478
486
  const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
479
487
  (right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
@@ -484,25 +492,107 @@ function frame(now) {
484
492
  hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
485
493
  return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
486
494
  }
487
- if (layout === 'flat') {
488
- for (let ring = 0; ring < 3; ring++) {
489
- const members = memories.slice(Math.ceil(memories.length * ring / 3), Math.ceil(memories.length * (ring + 1) / 3));
490
- const radius = [30, 55, 80][ring];
491
- members.forEach((node, index) => {
492
- const angle = index / members.length * Math.PI * 2;
493
- positions.set(node.id, { x: Math.cos(angle) * radius + jitter(node.id, 'x') * 3,
494
- y: Math.sin(angle) * radius + jitter(node.id, 'y') * 3, z: 0 });
495
- });
496
- }
497
- } else {
495
+ let datasetSeed = 20261030;
496
+ for (const id of memories.map(node => String(node.id)).sort()) {
497
+ for (const character of id + '\u0000') datasetSeed = Math.imul(datasetSeed ^ character.codePointAt(0), 16777619);
498
+ }
499
+ const uniform = (id, axis) => Math.max(1e-9, Math.min(1 - 1e-9, (jitter(datasetSeed + ':' + id, axis) + 1) / 2));
500
+ const gaussian = (id, axis) => Math.sqrt(-2 * Math.log(uniform(id, axis + ':radius'))) * Math.cos(2 * Math.PI * uniform(id, axis + ':angle'));
501
+ if (layout === 'galaxy') {
502
+ const armCount = 2 + Math.floor(uniform('galaxy', 'arms') * 3);
503
+ const rotation = uniform('galaxy', 'rotation') * Math.PI * 2;
504
+ const coreCount = Math.ceil(memories.length * 0.15);
505
+ const haloCount = Math.floor(memories.length * 0.05);
506
+ const diskEnd = memories.length - haloCount;
507
+ const dustCount = Math.round(memories.length * 0.30);
508
+ const dust = new Set(memories.slice(coreCount, diskEnd)
509
+ .sort((left, right) => uniform(left.id, 'dust') - uniform(right.id, 'dust') || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
510
+ .slice(0, Math.max(0, dustCount - haloCount)).map(node => node.id));
498
511
  memories.forEach((node, index) => {
499
- const fraction = index / Math.max(1, memories.length - 1);
500
- const angle = fraction * 3.2 * Math.PI + index % 3 * Math.PI * 2 / 3 + jitter(node.id, 'angle') * 0.35;
501
- const radius = 18 + fraction * 92;
502
- positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: jitter(node.id, 'z') * 14 });
512
+ let radius, angle;
513
+ if (index < coreCount) {
514
+ radius = 6 + Math.sqrt(uniform(node.id, 'core-radius')) * 16;
515
+ angle = uniform(node.id, 'core-angle') * Math.PI * 2;
516
+ } else if (index >= diskEnd) {
517
+ radius = 95 + Math.sqrt(uniform(node.id, 'halo-radius')) * 35;
518
+ angle = uniform(node.id, 'halo-angle') * Math.PI * 2;
519
+ } else if (dust.has(node.id)) {
520
+ radius = 22 + Math.sqrt(uniform(node.id, 'dust-radius')) * 73;
521
+ angle = uniform(node.id, 'dust-angle') * Math.PI * 2;
522
+ } else {
523
+ const fraction = (index - coreCount + 0.5) / Math.max(1, diskEnd - coreCount);
524
+ const offset = fraction * 73 + gaussian(node.id, 'radius') * 9;
525
+ const folded = ((offset % 146) + 146) % 146;
526
+ radius = 22 + (folded <= 73 ? folded : 146 - folded);
527
+ const arm = Math.floor(uniform(node.id, 'arm') * armCount);
528
+ const base = rotation + arm * Math.PI * 2 / armCount + (uniform(arm, 'arm-offset') - 0.5) * 0.35;
529
+ const twist = (radius - 18) / 95 * 2.2 * Math.PI;
530
+ const ripple = Math.sin(radius * 0.055 + uniform(arm, 'phase') * Math.PI * 2) * 0.10;
531
+ angle = base + twist + ripple + gaussian(node.id, 'angle') * 0.30;
532
+ }
533
+ const thickness = 8 / (1 + radius / 40);
534
+ const height = Math.max(-1, Math.min(1, gaussian(node.id, 'height') * 0.5)) * thickness;
535
+ positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: height });
536
+ });
537
+ } else {
538
+ function brainPoint(id, axis, extent = 1) {
539
+ const horizontal = gaussian(id, axis + ':x');
540
+ const vertical = gaussian(id, axis + ':y');
541
+ const depth = gaussian(id, axis + ':z');
542
+ const scale = Math.cbrt(uniform(id, axis + ':distance')) * extent / Math.hypot(horizontal, vertical, depth);
543
+ return { x: 42 + horizontal * scale * 40, y: vertical * scale * 106, z: depth * scale * 52 };
544
+ }
545
+ const centers = [0, 1].map(side => {
546
+ const count = 12 + Math.floor(uniform('brain:' + side, 'clusters') * 3);
547
+ const candidates = Array.from({ length: 96 }, (_, index) => {
548
+ const id = 'brain:' + side + ':' + index;
549
+ const point = brainPoint(id, 'center', 0.82 + uniform(id, 'surface') * 0.12);
550
+ return { ...point, spread: 6 + uniform(id, 'spread') * 8, phase: uniform(id, 'phase') * Math.PI * 2 };
551
+ });
552
+ const interior = candidates.reduce((best, point, index) => Math.hypot(point.x - 42, point.y, point.z)
553
+ < Math.hypot(candidates[best].x - 42, candidates[best].y, candidates[best].z) ? index : best, 0);
554
+ const selected = [candidates.splice(interior, 1)[0]];
555
+ while (selected.length < count) {
556
+ let best = 0, greatest = -Infinity;
557
+ candidates.forEach((point, index) => {
558
+ const distance = Math.min(...selected.map(center => Math.hypot(point.x - center.x, point.y - center.y, (point.z - center.z) * 0.7)));
559
+ if (distance > greatest) { greatest = distance; best = index; }
560
+ });
561
+ selected.push(candidates.splice(best, 1)[0]);
562
+ }
563
+ return selected;
564
+ });
565
+ const roamers = new Set([...memories].sort((left, right) => uniform(left.id, 'roaming') - uniform(right.id, 'roaming')
566
+ || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
567
+ .slice(0, Math.round(memories.length * 0.05)).map(node => node.id));
568
+ memories.forEach(node => {
569
+ const side = uniform(node.id, 'hemisphere') < 0.5 ? 0 : 1;
570
+ const target = brainPoint(node.id, 'target');
571
+ let center, nearest = Infinity;
572
+ centers[side].forEach((candidate, index) => {
573
+ const distance = Math.hypot(target.x - candidate.x, target.y - candidate.y, target.z - candidate.z)
574
+ * (0.8 + uniform(node.id, 'weight:' + index) * 0.4);
575
+ if (distance < nearest) { nearest = distance; center = candidate; }
576
+ });
577
+ const point = roamers.has(node.id) ? target : {
578
+ x: center.x + gaussian(node.id, 'scatter:x') * center.spread,
579
+ y: center.y + gaussian(node.id, 'scatter:y') * center.spread,
580
+ z: center.z + gaussian(node.id, 'scatter:z') * center.spread,
581
+ };
582
+ point.x += Math.sin(point.y * 0.08 + center.phase) * 6;
583
+ point.z += Math.sin(point.y * 0.09 + center.phase) * 4;
584
+ point.y += Math.sin(point.z * 0.12 + center.phase) * 3;
585
+ const envelope = 0.96 + 0.06 * Math.sin(point.y * 0.08) + 0.035 * Math.cos(point.z * 0.11 + side);
586
+ const distance = Math.hypot((point.x - 42) / 40, point.y / 106, point.z / 52);
587
+ if (distance > envelope) {
588
+ const scale = envelope * (0.93 + uniform(node.id, 'edge') * 0.06) / distance;
589
+ point.x = 42 + (point.x - 42) * scale; point.y *= scale; point.z *= scale;
590
+ }
591
+ positions.set(node.id, { x: (side ? 1 : -1) * (4 + Math.abs(point.x)) + (side ? 2 : 0),
592
+ y: point.y - (side ? 1 : 0), z: point.z });
503
593
  });
504
594
  }
505
- return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
595
+ return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: layout === 'brain' ? 14 : 0, z: 0 } : positions.get(node.id) }));
506
596
  }
507
597
 
508
598
  var defaultSettings = { hideButton: false, showGuideOnEmpty: true, graphTheme: "classic", graphLayout: "sphere", autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
@@ -511,7 +601,7 @@ function frame(now) {
511
601
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
512
602
  var appearanceOptions = [
513
603
  { key: "graphTheme", label: "配色", choices: [["classic", "经典星空"], ["neon", "赛博霓虹"], ["paper", "铅绘素描"], ["mono", "极简单色"]] },
514
- { key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["flat", "平面圆"], ["spiral", "螺旋银河"]] },
604
+ { key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["galaxy", "星系"], ["brain", "大脑神经网络"]] },
515
605
  ];
516
606
  var appearanceStyles = {
517
607
  classic: {},
package/lib/client.src.js CHANGED
@@ -22,7 +22,7 @@ window.__ModuleLoader__.load({
22
22
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
23
23
  var appearanceOptions = [
24
24
  { key: "graphTheme", label: "配色", choices: [["classic", "经典星空"], ["neon", "赛博霓虹"], ["paper", "铅绘素描"], ["mono", "极简单色"]] },
25
- { key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["flat", "平面圆"], ["spiral", "螺旋银河"]] },
25
+ { key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["galaxy", "星系"], ["brain", "大脑神经网络"]] },
26
26
  ];
27
27
  var appearanceStyles = {
28
28
  classic: {},
@@ -16,7 +16,7 @@ const writable = new Map([
16
16
  ['autoMemory.dailyCap', value => Number.isSafeInteger(value) && value >= 0 && value <= 500],
17
17
  ['spinSpeed', value => Number.isFinite(value) && value >= 0.05 && value <= 1.5],
18
18
  ['graphTheme', value => ['classic', 'neon', 'paper', 'mono'].includes(value)],
19
- ['graphLayout', value => ['sphere', 'flat', 'spiral'].includes(value)],
19
+ ['graphLayout', value => ['sphere', 'galaxy', 'brain', 'flat', 'spiral'].includes(value)],
20
20
  ['showGuideOnEmpty', value => typeof value === 'boolean'],
21
21
  ['hideButton', value => typeof value === 'boolean'],
22
22
  ]);
@@ -61,6 +61,10 @@ export function updateConfig(home, changes, { now = Date.now, warn = () => {} }
61
61
  try { next.autoImport.sessionFilter = normalizeSessionFilter({ ...current.autoImport?.sessionFilter, ...changes.autoImport.sessionFilter }); }
62
62
  catch (error) { invalid(error.message); }
63
63
  }
64
+ if (Object.hasOwn(changes, 'graphLayout')) {
65
+ if (next.graphLayout === 'flat') next.graphLayout = 'galaxy';
66
+ if (next.graphLayout === 'spiral') next.graphLayout = 'brain';
67
+ }
64
68
  if (changes.autoMemory?.enabled === true) next.showAutoMemoryCostHint = false;
65
69
  fs.mkdirSync(directory, { recursive: true });
66
70
  const prefix = `config.json.bak-${now()}`;
package/lib/config.js CHANGED
@@ -53,7 +53,9 @@ export function readConfig(home = defaultHome(), { warn } = {}) {
53
53
  if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
54
54
  throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
55
55
  }
56
- for (const [key, choices] of [['graphTheme', ['classic', 'neon', 'paper', 'mono']], ['graphLayout', ['sphere', 'flat', 'spiral']]]) {
56
+ if (value.graphLayout === 'flat') value.graphLayout = 'galaxy';
57
+ if (value.graphLayout === 'spiral') value.graphLayout = 'brain';
58
+ for (const [key, choices] of [['graphTheme', ['classic', 'neon', 'paper', 'mono']], ['graphLayout', ['sphere', 'galaxy', 'brain']]]) {
57
59
  if (value[key] !== undefined && !choices.includes(value[key])) throw new Error(`${key} 必须是 ${choices.join('、')}`);
58
60
  }
59
61
  return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35,
@@ -38,7 +38,9 @@ export function createRenderer(canvas, data, options = {}) {
38
38
  if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
39
39
  let theme = THEMES[themeName];
40
40
  let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
41
- if (!['sphere', 'flat', 'spiral'].includes(layoutName)) throw new Error('未知图谱布局');
41
+ if (layoutName === 'flat') layoutName = 'galaxy';
42
+ if (layoutName === 'spiral') layoutName = 'brain';
43
+ if (!['sphere', 'galaxy', 'brain'].includes(layoutName)) throw new Error('未知图谱布局');
42
44
  let sphereNodes = [];
43
45
  const layouts = new Map();
44
46
  let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
@@ -86,7 +88,9 @@ function glowSprite(rgb) {
86
88
 
87
89
  function setLayout(nextLayout) {
88
90
  if (destroyed) return;
89
- if (!['sphere', 'flat', 'spiral'].includes(nextLayout)) throw new Error('未知图谱布局');
91
+ if (nextLayout === 'flat') nextLayout = 'galaxy';
92
+ if (nextLayout === 'spiral') nextLayout = 'brain';
93
+ if (!['sphere', 'galaxy', 'brain'].includes(nextLayout)) throw new Error('未知图谱布局');
90
94
  if (layoutName === nextLayout && layouts.has(nextLayout)) return;
91
95
  if (!layouts.has(nextLayout)) {
92
96
  if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
@@ -311,20 +315,22 @@ function frame(now) {
311
315
  }
312
316
  }
313
317
  if (linkPaths.length) {
318
+ if (layoutName === 'galaxy') ctx.globalAlpha=0.8;
314
319
  if (Array.isArray(theme.link)) {
315
320
  const gradient = ctx.createLinearGradient(0,0,width,height);
316
321
  gradient.addColorStop(0,theme.link[0]);
317
322
  gradient.addColorStop(1,theme.link[1]);
318
323
  ctx.strokeStyle=gradient;
319
324
  } else ctx.strokeStyle=theme.link;
320
- ctx.lineWidth=1;
325
+ ctx.lineWidth=layoutName === 'galaxy' ? 0.8 : 1;
321
326
  ctx.beginPath();
322
327
  for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
323
328
  ctx.stroke();
324
329
  // 更亮的一层,让关联线看得清
325
330
  ctx.strokeStyle=theme.linkHighlight;
326
- ctx.lineWidth=2;
331
+ ctx.lineWidth=layoutName === 'galaxy' ? 1.6 : 2;
327
332
  ctx.stroke();
333
+ if (layoutName === 'galaxy') ctx.globalAlpha=1;
328
334
  }
329
335
  for (let index=pulses.length-1;index>=0;index--) {
330
336
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -1,7 +1,9 @@
1
1
  const SEED = 20261030;
2
2
 
3
3
  export function layoutPositions(nodes, layout = 'sphere') {
4
- if (!['sphere', 'flat', 'spiral'].includes(layout)) throw new Error('未知图谱布局');
4
+ if (layout === 'flat') layout = 'galaxy';
5
+ if (layout === 'spiral') layout = 'brain';
6
+ if (!['sphere', 'galaxy', 'brain'].includes(layout)) throw new Error('未知图谱布局');
5
7
  if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
6
8
  const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
7
9
  (right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
@@ -12,25 +14,107 @@ export function layoutPositions(nodes, layout = 'sphere') {
12
14
  hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
13
15
  return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
14
16
  }
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 {
17
+ let datasetSeed = 20261030;
18
+ for (const id of memories.map(node => String(node.id)).sort()) {
19
+ for (const character of id + '\u0000') datasetSeed = Math.imul(datasetSeed ^ character.codePointAt(0), 16777619);
20
+ }
21
+ const uniform = (id, axis) => Math.max(1e-9, Math.min(1 - 1e-9, (jitter(datasetSeed + ':' + id, axis) + 1) / 2));
22
+ const gaussian = (id, axis) => Math.sqrt(-2 * Math.log(uniform(id, axis + ':radius'))) * Math.cos(2 * Math.PI * uniform(id, axis + ':angle'));
23
+ if (layout === 'galaxy') {
24
+ const armCount = 2 + Math.floor(uniform('galaxy', 'arms') * 3);
25
+ const rotation = uniform('galaxy', 'rotation') * Math.PI * 2;
26
+ const coreCount = Math.ceil(memories.length * 0.15);
27
+ const haloCount = Math.floor(memories.length * 0.05);
28
+ const diskEnd = memories.length - haloCount;
29
+ const dustCount = Math.round(memories.length * 0.30);
30
+ const dust = new Set(memories.slice(coreCount, diskEnd)
31
+ .sort((left, right) => uniform(left.id, 'dust') - uniform(right.id, 'dust') || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
32
+ .slice(0, Math.max(0, dustCount - haloCount)).map(node => node.id));
26
33
  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 });
34
+ let radius, angle;
35
+ if (index < coreCount) {
36
+ radius = 6 + Math.sqrt(uniform(node.id, 'core-radius')) * 16;
37
+ angle = uniform(node.id, 'core-angle') * Math.PI * 2;
38
+ } else if (index >= diskEnd) {
39
+ radius = 95 + Math.sqrt(uniform(node.id, 'halo-radius')) * 35;
40
+ angle = uniform(node.id, 'halo-angle') * Math.PI * 2;
41
+ } else if (dust.has(node.id)) {
42
+ radius = 22 + Math.sqrt(uniform(node.id, 'dust-radius')) * 73;
43
+ angle = uniform(node.id, 'dust-angle') * Math.PI * 2;
44
+ } else {
45
+ const fraction = (index - coreCount + 0.5) / Math.max(1, diskEnd - coreCount);
46
+ const offset = fraction * 73 + gaussian(node.id, 'radius') * 9;
47
+ const folded = ((offset % 146) + 146) % 146;
48
+ radius = 22 + (folded <= 73 ? folded : 146 - folded);
49
+ const arm = Math.floor(uniform(node.id, 'arm') * armCount);
50
+ const base = rotation + arm * Math.PI * 2 / armCount + (uniform(arm, 'arm-offset') - 0.5) * 0.35;
51
+ const twist = (radius - 18) / 95 * 2.2 * Math.PI;
52
+ const ripple = Math.sin(radius * 0.055 + uniform(arm, 'phase') * Math.PI * 2) * 0.10;
53
+ angle = base + twist + ripple + gaussian(node.id, 'angle') * 0.30;
54
+ }
55
+ const thickness = 8 / (1 + radius / 40);
56
+ const height = Math.max(-1, Math.min(1, gaussian(node.id, 'height') * 0.5)) * thickness;
57
+ positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius, z: height });
58
+ });
59
+ } else {
60
+ function brainPoint(id, axis, extent = 1) {
61
+ const horizontal = gaussian(id, axis + ':x');
62
+ const vertical = gaussian(id, axis + ':y');
63
+ const depth = gaussian(id, axis + ':z');
64
+ const scale = Math.cbrt(uniform(id, axis + ':distance')) * extent / Math.hypot(horizontal, vertical, depth);
65
+ return { x: 42 + horizontal * scale * 40, y: vertical * scale * 106, z: depth * scale * 52 };
66
+ }
67
+ const centers = [0, 1].map(side => {
68
+ const count = 12 + Math.floor(uniform('brain:' + side, 'clusters') * 3);
69
+ const candidates = Array.from({ length: 96 }, (_, index) => {
70
+ const id = 'brain:' + side + ':' + index;
71
+ const point = brainPoint(id, 'center', 0.82 + uniform(id, 'surface') * 0.12);
72
+ return { ...point, spread: 6 + uniform(id, 'spread') * 8, phase: uniform(id, 'phase') * Math.PI * 2 };
73
+ });
74
+ const interior = candidates.reduce((best, point, index) => Math.hypot(point.x - 42, point.y, point.z)
75
+ < Math.hypot(candidates[best].x - 42, candidates[best].y, candidates[best].z) ? index : best, 0);
76
+ const selected = [candidates.splice(interior, 1)[0]];
77
+ while (selected.length < count) {
78
+ let best = 0, greatest = -Infinity;
79
+ candidates.forEach((point, index) => {
80
+ const distance = Math.min(...selected.map(center => Math.hypot(point.x - center.x, point.y - center.y, (point.z - center.z) * 0.7)));
81
+ if (distance > greatest) { greatest = distance; best = index; }
82
+ });
83
+ selected.push(candidates.splice(best, 1)[0]);
84
+ }
85
+ return selected;
86
+ });
87
+ const roamers = new Set([...memories].sort((left, right) => uniform(left.id, 'roaming') - uniform(right.id, 'roaming')
88
+ || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0))
89
+ .slice(0, Math.round(memories.length * 0.05)).map(node => node.id));
90
+ memories.forEach(node => {
91
+ const side = uniform(node.id, 'hemisphere') < 0.5 ? 0 : 1;
92
+ const target = brainPoint(node.id, 'target');
93
+ let center, nearest = Infinity;
94
+ centers[side].forEach((candidate, index) => {
95
+ const distance = Math.hypot(target.x - candidate.x, target.y - candidate.y, target.z - candidate.z)
96
+ * (0.8 + uniform(node.id, 'weight:' + index) * 0.4);
97
+ if (distance < nearest) { nearest = distance; center = candidate; }
98
+ });
99
+ const point = roamers.has(node.id) ? target : {
100
+ x: center.x + gaussian(node.id, 'scatter:x') * center.spread,
101
+ y: center.y + gaussian(node.id, 'scatter:y') * center.spread,
102
+ z: center.z + gaussian(node.id, 'scatter:z') * center.spread,
103
+ };
104
+ point.x += Math.sin(point.y * 0.08 + center.phase) * 6;
105
+ point.z += Math.sin(point.y * 0.09 + center.phase) * 4;
106
+ point.y += Math.sin(point.z * 0.12 + center.phase) * 3;
107
+ const envelope = 0.96 + 0.06 * Math.sin(point.y * 0.08) + 0.035 * Math.cos(point.z * 0.11 + side);
108
+ const distance = Math.hypot((point.x - 42) / 40, point.y / 106, point.z / 52);
109
+ if (distance > envelope) {
110
+ const scale = envelope * (0.93 + uniform(node.id, 'edge') * 0.06) / distance;
111
+ point.x = 42 + (point.x - 42) * scale; point.y *= scale; point.z *= scale;
112
+ }
113
+ positions.set(node.id, { x: (side ? 1 : -1) * (4 + Math.abs(point.x)) + (side ? 2 : 0),
114
+ y: point.y - (side ? 1 : 0), z: point.z });
31
115
  });
32
116
  }
33
- return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
117
+ return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: layout === 'brain' ? 14 : 0, z: 0 } : positions.get(node.id) }));
34
118
  }
35
119
 
36
120
  export function layoutMemories(nodes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-engine",
3
- "version": "0.7.7",
3
+ "version": "0.8.1",
4
4
  "description": "DSH 本地记忆插件:自动记忆、遗忘曲线、关键词关联、可检索可编辑,数据留在本机。",
5
5
  "keywords": [
6
6
  "dsh",