dsh-memory-engine 0.7.5 → 0.7.7

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,30 @@
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)
4
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+ [![tests](https://img.shields.io/badge/tests-277%20passed-brightgreen)](test)
6
+
3
7
  DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
4
8
 
5
- 当前版本:0.7.5。Node ≥22,零新增依赖。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收聊天会话的用户消息,支持来源白名单、工作指令过滤、流程话过滤、规则分类、错误原因、一次性重扫和账本清理。
9
+ **仓库**:https://github.com/jiudouliang/dsh-memory-engine
10
+ **安装**:`pnpm add dsh-memory-engine`(装完重启 DSH,标题栏出现「记忆图谱」)
11
+
12
+ ![记忆图谱](docs/screenshot.png)
13
+
14
+ 当前版本:0.8.0。Node ≥22,零新增依赖。新增四套配色与三种布局,默认保留原来的经典星空与球体。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收聊天会话的用户消息,支持来源白名单、工作指令过滤、流程话过滤、规则分类、错误原因、一次性重扫和账本清理。
15
+
16
+ ## 0.8.0 图谱外观
17
+
18
+ 图谱头部点击「外观」,可组合选择四套配色与三种布局;「设置」页也有同一组选项。点选立即应用并保存,刷新后保留;保存失败会回滚并显示原因。「恢复默认」同时恢复经典星空和球体。
19
+
20
+ | 维度 | 选项 |
21
+ | --- | --- |
22
+ | 配色 | 经典星空 `classic` / 赛博霓虹 `neon` / 铅绘素描 `paper` / 极简单色 `mono` |
23
+ | 布局 | 球体 `sphere` / 平面圆 `flat` / 螺旋银河 `spiral` |
24
+
25
+ 铅绘素描采用素纸底色、石墨灰节点和极淡光晕。平面圆按连接数分为三个同心环;螺旋银河沿三条旋臂排列。三种布局都保留角色卡中心、旋转、缩放和点击详情。切换时保留已选节点、相机及动画状态,不重新请求记忆数据,不改变记忆的重要度、留存或节点尺寸算法。
26
+
27
+ 在现有配置中可合并 `"graphTheme": "classic"` 和 `"graphLayout": "sphere"`。这两个字段通过现有配置接口和备份、原子写流程保存,非法值返回 400。未配置时外观与升级前相同;没有角色卡的库仍保留原来的默认脑状布局。CLI 生成的离线图谱也读取同一配置。
6
28
 
7
29
  ## 从 0.6.1 升级到 0.7.0 的变化
8
30
 
@@ -111,7 +133,7 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
111
133
 
112
134
  `lib/render/canvas.js` 是唯一绘制源码,导出 `createRenderer(canvas, data, options)`,每个实例独立维护相机、节点缓存、脉冲和动画生命周期。它不导入模块,也不依赖 ModuleLoader。
113
135
 
114
- `lib/client.src.js` 是 ModuleLoader 工厂模板;修改它或 Canvas 源码后运行 `pnpm build`(也可 `npm run build`),生成单文件 `lib/client.js`。产物头部标有「别直接改这个文件」。构建先写同目录临时文件,再原子替换;相同输入得到逐字节相同的产物,失败会非零退出。
136
+ `lib/client.src.js` 是 ModuleLoader 工厂模板;修改它、Canvas 源码或布局源码后运行 `pnpm build`(也可 `npm run build`),生成单文件 `lib/client.js`。`layout.mjs` 中的 `layoutPositions()` 是新布局的唯一实现,构建与离线 HTML 均直接内联它;默认球体仍沿用原有服务器布局,切换回来恢复原坐标。产物头部标有「别直接改这个文件」。构建先写同目录临时文件,再原子替换;相同输入得到逐字节相同的产物,失败会非零退出。
115
137
 
116
138
  `lib/render/render.mjs` 只保留 HTML、标题、图例、复习清单和原有详情外壳,读取同一份 Canvas 源码并内联。两个消费者共用 `scripts/lib/strip-export.mjs` 去掉导出声明,发布客户端和离线 HTML 都不需要运行时模块加载。离线 HTML 可直接双击打开。
117
139
 
@@ -122,10 +144,10 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
122
144
  | 方法 | 路径 | 返回内容 |
123
145
  | --- | --- | --- |
124
146
  | GET | `/memory-engine/` | 独立浏览器可直接访问的整页 HTML 图谱;应用内界面不再加载此页面 |
125
- | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt, core, spinSpeed }`;配置角色卡时 nodes[0] 为 isCore,core 含 id/title/preview,否则为 null;stats 统计图谱节点 |
147
+ | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt, core, spinSpeed, graphTheme, graphLayout }`;nodes 保留原始球体坐标,客户端按 graphLayout 显示;配置角色卡时 nodes[0] 为 isCore,core 含 id/title/preview,否则为 null;stats 统计图谱节点 |
126
148
  | GET | `/memory-engine/api/stats` | 记忆总数、分类、衰减状态及存储位置 |
127
149
  | GET | `/memory-engine/api/onboard` | `{ empty, prompt, steps, dataDir }`,空库和手动帮助共用 |
128
- | GET | `/memory-engine/api/config` | 保留既有字段,`autoMemory` 增加 `dailyCap`,另增加 `showAutoMemoryCostHint`;仍返回 `autoImport: { enabled, sourceCount }` 和 `lastError: { at, stage, message }`,不返回 raw、来源路径或模型配置 |
150
+ | GET | `/memory-engine/api/config` | 包含 `graphTheme`、`graphLayout`,保留既有公开字段;不返回 raw、来源路径或模型配置 |
129
151
  | PUT | `/memory-engine/api/config` | 合并合法白名单字段,返回公开配置和 `backup` 文件名 |
130
152
  | GET | `/memory-engine/api/list?q=&kind=&sort=&limit=&offset=` | `{ total, allTotal, limit, offset, items }`;包含留存、连接数和 60 字预览 |
131
153
  | GET | `/memory-engine/api/item/:id` | 完整条目、留存和连接数;不存在返回 404 |
@@ -420,4 +442,4 @@ node --test test/*.test.mjs
420
442
  ## 许可
421
443
 
422
444
  MIT。可以商用、可以修改、可以再发布,保留版权声明即可(见 [LICENSE](./LICENSE))。
423
-
445
+
package/lib/client.js CHANGED
@@ -20,8 +20,43 @@ window.__ModuleLoader__.load({
20
20
  const performance = window.performance;
21
21
  const ctx = canvas.getContext('2d', { alpha: false });
22
22
  if (!ctx) throw new Error('Canvas 2D 不可用');
23
- const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
24
- const fallback = [145,205,255];
23
+ const THEMES = {
24
+ classic: {
25
+ colors: { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] },
26
+ fallback:[145,205,255], background:'#03060c', grid:'rgba(40,90,140,0.055)',
27
+ link:'rgba(110,190,255,0.30)', linkHighlight:'rgba(160,220,255,0.22)',
28
+ pulse:[205,245,255], center:[255,255,255], fading:[255,110,110], glow:[0.7,0.19],
29
+ vignette:['rgba(0,0,0,0)','rgba(0,0,0,.78)'],
30
+ },
31
+ neon: {
32
+ colors: { '人格':[255,255,255], '印象':[63,242,224], '知识':[255,95,210], '事件':[165,107,255], '日记':[192,125,255], '对话':[255,147,233], '情绪':[255,66,130] },
33
+ fallback:[145,143,255], background:'#0a0410', grid:'rgba(156,70,210,0.07)',
34
+ link:['rgba(255,95,210,0.36)','rgba(165,107,255,0.36)'], linkHighlight:'rgba(224,175,255,0.24)',
35
+ pulse:[245,210,255], center:[255,255,255], fading:[255,90,165], glow:[0.7,0.19],
36
+ vignette:['rgba(10,4,16,0)','rgba(10,4,16,.78)'],
37
+ },
38
+ paper: {
39
+ colors: { '人格':[40,40,40], '印象':[107,107,107], '知识':[64,64,64], '事件':[85,85,85], '日记':[133,133,133], '对话':[116,116,116], '情绪':[76,76,76] },
40
+ fallback:[96,96,96], background:'#f7f5f0', grid:'rgba(80,78,73,0.022)',
41
+ link:'rgba(65,65,65,0.34)', linkHighlight:'rgba(100,100,100,0.055)',
42
+ pulse:[48,48,48], center:[36,36,36], fading:[126,126,126], glow:[0.025,0.006],
43
+ vignette:['rgba(135,132,125,0)','rgba(135,132,125,.08)'],
44
+ },
45
+ mono: {
46
+ colors: { '人格':[232,232,232], '印象':[200,200,200], '知识':[255,255,255], '事件':[138,138,138], '日记':[174,174,174], '对话':[218,218,218], '情绪':[160,160,160] },
47
+ fallback:[185,185,185], background:'#0d0d0d', grid:'rgba(130,130,130,0.045)',
48
+ link:'rgba(178,178,178,0.30)', linkHighlight:'rgba(210,210,210,0.22)',
49
+ pulse:[235,235,235], center:[255,255,255], fading:[150,150,150], glow:[0.7,0.19],
50
+ vignette:['rgba(0,0,0,0)','rgba(0,0,0,.78)'],
51
+ },
52
+ };
53
+ let themeName = options.graphTheme ?? data.graphTheme ?? 'classic';
54
+ if (!Object.hasOwn(THEMES, themeName)) throw new Error('未知图谱配色');
55
+ let theme = THEMES[themeName];
56
+ let layoutName = options.graphLayout ?? data.graphLayout ?? 'sphere';
57
+ if (!['sphere', 'flat', 'spiral'].includes(layoutName)) throw new Error('未知图谱布局');
58
+ let sphereNodes = [];
59
+ const layouts = new Map();
25
60
  let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
26
61
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
27
62
  const pulses = [], firing = new Map(), selections = new Set();
@@ -40,13 +75,44 @@ function glowSprite(rgb) {
40
75
  off.width = off.height = 96;
41
76
  const brush = off.getContext('2d');
42
77
  const gradient = brush.createRadialGradient(48,48,1,48,48,48);
43
- gradient.addColorStop(0,'rgba('+rgb.join(',')+',0.7)');
44
- gradient.addColorStop(0.4,'rgba('+rgb.join(',')+',0.19)');
78
+ gradient.addColorStop(0,'rgba('+rgb.join(',')+','+theme.glow[0]+')');
79
+ gradient.addColorStop(0.4,'rgba('+rgb.join(',')+','+theme.glow[1]+')');
45
80
  gradient.addColorStop(1,'rgba('+rgb.join(',')+',0)');
46
81
  brush.fillStyle = gradient; brush.fillRect(0,0,96,96);
47
82
  return off;
48
83
  }
49
84
 
85
+ function recolor() {
86
+ sprites.clear();
87
+ for (const star of nodes) {
88
+ star._rgb = theme.colors[star.kind] || theme.fallback;
89
+ const key = star._rgb.join(',');
90
+ if (!sprites.has(key)) sprites.set(key, glowSprite(star._rgb));
91
+ star._glow = sprites.get(key);
92
+ }
93
+ }
94
+
95
+ function setTheme(nextTheme) {
96
+ if (destroyed || nextTheme === themeName) return;
97
+ if (!Object.hasOwn(THEMES, nextTheme)) throw new Error('未知图谱配色');
98
+ themeName = nextTheme;
99
+ theme = THEMES[nextTheme];
100
+ recolor();
101
+ }
102
+
103
+ function setLayout(nextLayout) {
104
+ if (destroyed) return;
105
+ if (!['sphere', 'flat', 'spiral'].includes(nextLayout)) throw new Error('未知图谱布局');
106
+ if (layoutName === nextLayout && layouts.has(nextLayout)) return;
107
+ if (!layouts.has(nextLayout)) {
108
+ if (typeof options.layoutNodes !== 'function') throw new Error('缺少图谱布局函数');
109
+ layouts.set(nextLayout, options.layoutNodes(sphereNodes, nextLayout).map(node => ({ ...node.pos })));
110
+ }
111
+ const positions = layouts.get(nextLayout);
112
+ nodes.forEach((node, index) => { node.pos = { ...positions[index] }; });
113
+ layoutName = nextLayout;
114
+ }
115
+
50
116
  // 字符串 → 32 位无符号哈希。给连线的闪烁参数做种子:
51
117
  // 用 id 而不是 Math.random,保证同一条线在每次打开时节奏一致(不会每次刷新都跳),
52
118
  // 但不同连线之间互不同步。
@@ -63,6 +129,10 @@ function glowSprite(rgb) {
63
129
  if (destroyed) return;
64
130
  spinSpeed=nextData.spinSpeed??0.35;
65
131
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
132
+ sphereNodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
133
+ layouts.clear();
134
+ layouts.set('sphere', sphereNodes.map(node => ({ ...node.pos })));
135
+ if (layoutName !== 'sphere') setLayout(layoutName);
66
136
  links = nextData.links.map(link => [...link]);
67
137
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
68
138
  selectedId=typeof options.selectedId==='string'?options.selectedId:null;
@@ -110,7 +180,6 @@ function glowSprite(rgb) {
110
180
  .slice(0, LINK_DRAW_CAP);
111
181
  }
112
182
  for (const star of nodes) {
113
- star._rgb = colors[star.kind] || fallback;
114
183
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
115
184
  star._dendrites = Array.from({length:count},(_,index) => ({
116
185
  angle:index/count*Math.PI*2+random()*0.7,
@@ -118,11 +187,7 @@ for (const star of nodes) {
118
187
  fork:random()<0.55 ? (random()-0.5)*1.5 : null
119
188
  }));
120
189
  }
121
- for (const star of nodes) {
122
- const key = star._rgb.join(',');
123
- if (!sprites.has(key)) sprites.set(key,glowSprite(star._rgb));
124
- star._glow = sprites.get(key);
125
- }
190
+ recolor();
126
191
 
127
192
  }
128
193
 
@@ -226,8 +291,8 @@ function frame(now) {
226
291
  }
227
292
  nextFire=now+1400+Math.random()*3200;
228
293
  }
229
- ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
230
- ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
294
+ ctx.globalCompositeOperation='source-over'; ctx.fillStyle=theme.background; ctx.fillRect(0,0,width,height);
295
+ ctx.strokeStyle=theme.grid;ctx.lineWidth=1;
231
296
  for (let index=0;index<20;index++) {
232
297
  const y=index/20*height;
233
298
  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();
@@ -262,13 +327,18 @@ function frame(now) {
262
327
  }
263
328
  }
264
329
  if (linkPaths.length) {
265
- ctx.strokeStyle='rgba(110,190,255,0.30)';
330
+ if (Array.isArray(theme.link)) {
331
+ const gradient = ctx.createLinearGradient(0,0,width,height);
332
+ gradient.addColorStop(0,theme.link[0]);
333
+ gradient.addColorStop(1,theme.link[1]);
334
+ ctx.strokeStyle=gradient;
335
+ } else ctx.strokeStyle=theme.link;
266
336
  ctx.lineWidth=1;
267
337
  ctx.beginPath();
268
338
  for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
269
339
  ctx.stroke();
270
340
  // 更亮的一层,让关联线看得清
271
- ctx.strokeStyle='rgba(160,220,255,0.22)';
341
+ ctx.strokeStyle=theme.linkHighlight;
272
342
  ctx.lineWidth=2;
273
343
  ctx.stroke();
274
344
  }
@@ -281,7 +351,7 @@ function frame(now) {
281
351
  const inverse=1-fraction;
282
352
  const x=inverse*inverse*first.p.x+2*inverse*fraction*mx+fraction*fraction*second.p.x;
283
353
  const y=inverse*inverse*first.p.y+2*inverse*fraction*my+fraction*fraction*second.p.y;
284
- ctx.fillStyle='rgba(205,245,255,'+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
354
+ ctx.fillStyle='rgba('+theme.pulse.join(',')+','+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
285
355
  }
286
356
  for (const {star,p} of points) {
287
357
  const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
@@ -321,9 +391,9 @@ function frame(now) {
321
391
  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;
322
392
  ctx.fillStyle='rgba('+rgb+','+Math.min(1,.42*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
323
393
  ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.75*intensity)+')';ctx.lineWidth=1;ctx.stroke();
324
- 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();
394
+ 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();
325
395
  if (star.retention<.25) {
326
- ctx.strokeStyle='rgba(255,110,110,'+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
396
+ ctx.strokeStyle='rgba('+theme.fading.join(',')+','+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
327
397
  ctx.beginPath();ctx.arc(p.x,p.y,radius*3.2,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
328
398
  }
329
399
  star._screen={x:p.x,y:p.y,r:Math.max(8,radius*2.6)};
@@ -334,7 +404,7 @@ function frame(now) {
334
404
  }
335
405
  ctx.globalCompositeOperation='source-over';
336
406
  const vignette=ctx.createRadialGradient(width/2,height/2,Math.min(width,height)*.34,width/2,height/2,Math.max(width,height)*.8);
337
- vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.78)');
407
+ vignette.addColorStop(0,theme.vignette[0]);vignette.addColorStop(1,theme.vignette[1]);
338
408
  ctx.fillStyle=vignette;ctx.fillRect(0,0,width,height);
339
409
  }
340
410
  function start() {
@@ -387,12 +457,12 @@ function frame(now) {
387
457
  if (destroyed) return;
388
458
  stop(); destroyed=true;
389
459
  selections.clear(); byId.clear(); adjacency.clear(); sprites.clear();
390
- nodes=[]; links=[]; hubs=[];
460
+ nodes=[]; links=[]; hubs=[]; sphereNodes=[]; layouts.clear();
391
461
  }
392
462
 
393
463
  setData(data);
394
464
  return {
395
- start, stop, resize, setData, destroy,
465
+ start, stop, resize, setData, setTheme, setLayout, destroy,
396
466
  fireNeuron(id) { if (!destroyed && byId.has(id)) fire(id,performance.now()); },
397
467
  onSelect(callback) {
398
468
  if (!destroyed) selections.add(callback);
@@ -402,11 +472,71 @@ function frame(now) {
402
472
  }
403
473
 
404
474
 
475
+ function layoutPositions(nodes, layout = 'sphere') {
476
+ if (!['sphere', 'flat', 'spiral'].includes(layout)) throw new Error('未知图谱布局');
477
+ if (layout === 'sphere') return nodes.map(node => ({ ...node, pos: { ...node.pos } }));
478
+ const memories = nodes.filter(node => !node.isCore).sort((left, right) =>
479
+ (right.degree || 0) - (left.degree || 0) || (String(left.id) < String(right.id) ? -1 : String(left.id) > String(right.id) ? 1 : 0));
480
+ const positions = new Map();
481
+ function jitter(id, axis) {
482
+ let hash = 20261030;
483
+ for (const character of String(id) + ':' + axis) hash = Math.imul(hash ^ character.codePointAt(0), 16777619);
484
+ hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
485
+ return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295 * 2 - 1;
486
+ }
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 {
498
+ 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 });
503
+ });
504
+ }
505
+ return nodes.map(node => ({ ...node, pos: node.isCore ? { x: 0, y: 0, z: 0 } : positions.get(node.id) }));
506
+ }
405
507
 
406
- var defaultSettings = { hideButton: false, showGuideOnEmpty: true, autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
407
- var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid #6999b8", background: "#102236", color: "#e2f4ff", font: "inherit", cursor: "pointer" };
508
+ var defaultSettings = { hideButton: false, showGuideOnEmpty: true, graphTheme: "classic", graphLayout: "sphere", autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
509
+ var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid var(--memory-border,#6999b8)", background: "var(--memory-button,#102236)", color: "var(--memory-text,#e2f4ff)", font: "inherit", cursor: "pointer" };
408
510
  var fieldStyle = { boxSizing: "border-box", padding: "10px 12px", borderRadius: "8px", border: "1px solid #35536b", background: "#091422", color: "#e2f4ff", font: "inherit", minWidth: 0 };
409
511
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
512
+ var appearanceOptions = [
513
+ { key: "graphTheme", label: "配色", choices: [["classic", "经典星空"], ["neon", "赛博霓虹"], ["paper", "铅绘素描"], ["mono", "极简单色"]] },
514
+ { key: "graphLayout", label: "布局", choices: [["sphere", "球体"], ["flat", "平面圆"], ["spiral", "螺旋银河"]] },
515
+ ];
516
+ var appearanceStyles = {
517
+ classic: {},
518
+ neon: { "--memory-text": "#f4dfff", "--memory-muted": "#ba9aca", "--memory-accent": "#ff9de1", "--memory-border": "#704580", "--memory-panel": "#170d21ee", "--memory-button": "#271333", "--memory-shadow": "none" },
519
+ paper: { "--memory-text": "#303030", "--memory-muted": "#72716d", "--memory-accent": "#404040", "--memory-border": "#b5b1a9", "--memory-panel": "#f7f5f0f2", "--memory-button": "#eeece6", "--memory-shadow": "none" },
520
+ mono: { "--memory-text": "#eeeeee", "--memory-muted": "#aaaaaa", "--memory-accent": "#eeeeee", "--memory-border": "#666666", "--memory-panel": "#171717ee", "--memory-button": "#252525", "--memory-shadow": "none" },
521
+ };
522
+
523
+ function AppearanceControls(props) {
524
+ return React.createElement("section", { "aria-label": "图谱外观", "data-memory-engine-appearance-options": props.location,
525
+ style: { color: "var(--memory-text,#dbeaf8)", font: "14px/1.7 system-ui,sans-serif" } },
526
+ appearanceOptions.map(function (group) {
527
+ return React.createElement("fieldset", { key: group.key, disabled: props.busy, style: { border: 0, margin: "0 0 14px", padding: 0 } },
528
+ React.createElement("legend", { style: { marginBottom: "8px", color: "var(--memory-muted,#9fb7ce)" } }, group.label),
529
+ React.createElement("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px 14px" } }, group.choices.map(function (choice) {
530
+ return React.createElement("label", { key: choice[0], style: { display: "inline-flex", alignItems: "center", gap: "6px", cursor: props.busy ? "wait" : "pointer" } },
531
+ React.createElement("input", { type: "radio", name: "memory-" + props.location + "-" + group.key, value: choice[0],
532
+ "data-memory-engine-appearance-choice": group.key + ":" + choice[0],
533
+ checked: props.settings[group.key] === choice[0], style: { margin: 0, accentColor: "var(--memory-accent,#a4e6ff)" },
534
+ onChange: function () { var changes = {}; changes[group.key] = choice[0]; props.onChange(changes); } }), choice[1]);
535
+ })));
536
+ }), React.createElement("button", { type: "button", disabled: props.busy, style: actionStyle,
537
+ "data-memory-engine-appearance-reset": props.location,
538
+ onClick: function () { props.onChange({ graphTheme: "classic", graphLayout: "sphere" }); } }, "恢复默认"));
539
+ }
410
540
 
411
541
  function requestApi(url, options) {
412
542
  return fetch(url, Object.assign({ cache: "no-store" }, options)).then(function (response) {
@@ -560,6 +690,8 @@ function frame(now) {
560
690
 
561
691
  function MemoryCanvas(props) {
562
692
  var canvas = React.useRef(null);
693
+ var appearance = React.useRef(null);
694
+ appearance.current = { graphTheme: props.graphTheme || "classic", graphLayout: props.graphLayout || "sphere" };
563
695
  var dataState = React.useState(null);
564
696
  var data = dataState[0];
565
697
  var setData = dataState[1];
@@ -576,7 +708,7 @@ function frame(now) {
576
708
  setError("");
577
709
  readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
578
710
  if (!active) return;
579
- renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
711
+ renderer = createRenderer(canvas.current, nextData, Object.assign({ frameInterval: 25, layoutNodes: layoutPositions }, appearance.current));
580
712
  props.rendererRef.current = renderer;
581
713
  renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
582
714
  renderer.start();
@@ -595,6 +727,11 @@ function frame(now) {
595
727
  if (props.rendererRef.current === renderer) props.rendererRef.current = null;
596
728
  };
597
729
  }, [retry, props.rendererRef, props.selectionVersion, props.onSelect]);
730
+ React.useEffect(function () {
731
+ if (!props.rendererRef.current) return;
732
+ props.rendererRef.current.setTheme(appearance.current.graphTheme);
733
+ props.rendererRef.current.setLayout(appearance.current.graphLayout);
734
+ }, [props.graphTheme, props.graphLayout]);
598
735
  return React.createElement(React.Fragment, null,
599
736
  React.createElement("canvas", {
600
737
  ref: canvas, "data-memory-engine-canvas": "true", "aria-label": "记忆神经网络",
@@ -602,21 +739,21 @@ function frame(now) {
602
739
  }),
603
740
  React.createElement("div", {
604
741
  "data-memory-engine-summary": "true",
605
- style: { position: "absolute", top: "16px", left: "18px", pointerEvents: "none", color: "#d8e9f8", font: "13px/1.65 system-ui,sans-serif", textShadow: "0 0 12px #3388bb88" },
606
- }, React.createElement("strong", { style: { color: "#a4e6ff", fontSize: "16px" } }, "记忆神经网络"),
742
+ style: { position: "absolute", top: "16px", left: "18px", pointerEvents: "none", color: "var(--memory-text,#d8e9f8)", font: "13px/1.65 system-ui,sans-serif", textShadow: "var(--memory-shadow,0 0 12px #3388bb88)" },
743
+ }, React.createElement("strong", { style: { color: "var(--memory-accent,#a4e6ff)", fontSize: "16px" } }, "记忆神经网络"),
607
744
  React.createElement("div", null, data ? data.nodes.length + " 个神经元 · " + data.links.length + " 条突触" : "正在加载图谱……"),
608
- React.createElement("div", { style: { color: "#7491a8", fontSize: "12px" } }, "大小=重要度和连接数 · 亮度=留存")),
745
+ React.createElement("div", { style: { color: "var(--memory-muted,#7491a8)", fontSize: "12px" } }, "大小=重要度和连接数 · 亮度=留存")),
609
746
  data ? React.createElement("section", {
610
747
  "aria-label": "复习清单",
611
- style: { position: "absolute", bottom: "16px", left: "18px", width: "min(400px,calc(100% - 36px))", boxSizing: "border-box", maxHeight: "32%", overflow: "auto", padding: "15px", background: "#07111fee", border: "1px solid #3976a055", borderRadius: "14px", boxShadow: "0 0 40px #1763a033", backdropFilter: "blur(8px)", font: "12px/1.65 system-ui,sans-serif", color: "#b1c6d9" },
612
- }, React.createElement("h2", { style: { fontSize: "14px", color: "#ffdb82", margin: "0 0 8px" } }, "复习清单 · 留存最低的记忆"),
748
+ style: { position: "absolute", bottom: "16px", left: "18px", width: "min(400px,calc(100% - 36px))", boxSizing: "border-box", maxHeight: "32%", overflow: "auto", padding: "15px", background: "var(--memory-panel,#07111fee)", border: "1px solid var(--memory-border,#3976a055)", borderRadius: "14px", boxShadow: "var(--memory-shadow,0 0 40px #1763a033)", backdropFilter: "blur(8px)", font: "12px/1.65 system-ui,sans-serif", color: "var(--memory-text,#b1c6d9)" },
749
+ }, React.createElement("h2", { style: { fontSize: "14px", color: "var(--memory-accent,#ffdb82)", margin: "0 0 8px" } }, "复习清单 · 留存最低的记忆"),
613
750
  data.review.map(function (item, index) {
614
751
  return React.createElement("div", { key: item.id || index, style: { display: "flex", gap: "7px", alignItems: "center", margin: "5px 0" } },
615
752
  React.createElement("span", { style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (index + 1) + ". [" + item.kind + "] " + item.title),
616
- React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
617
- React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
753
+ React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "var(--memory-border,#335066)", overflow: "hidden" } },
754
+ React.createElement("i", { style: { display: "block", height: "100%", background: "var(--memory-accent,#6bc9fa)", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
618
755
  })) : null,
619
- React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
756
+ React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "var(--memory-muted,#7b99ad)", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
620
757
  error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
621
758
  React.createElement("p", null, "图谱加载失败:" + error),
622
759
  React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
@@ -904,6 +1041,9 @@ function frame(now) {
904
1041
  React.createElement("span", null, props.settings.autoImport.message), " ",
905
1042
  React.createElement("button", { type: "button", "data-memory-engine-use-auto-import": "true", disabled: props.configBusy,
906
1043
  onClick: props.onUseAutoImport, style: actionStyle }, "改用宽松模式")) : null,
1044
+ React.createElement("section", { style: { marginBottom: "24px", padding: "20px", border: "1px solid var(--memory-border,#263f54)", borderRadius: "10px", background: "var(--memory-panel,#0b1624)" } },
1045
+ React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "var(--memory-accent,#b7e9ff)" } }, "图谱外观"),
1046
+ React.createElement(AppearanceControls, { settings: props.settings, busy: props.configBusy, onChange: props.onAppearanceChange, location: "settings" })),
907
1047
  React.createElement("section", { style: { marginBottom: "24px", paddingBottom: "20px", borderBottom: "1px solid #263f54" } },
908
1048
  React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "#b7e9ff" } }, "自动记忆"),
909
1049
  React.createElement(AutoMemoryToggle, { settings: props.settings, busy: props.configBusy, onToggle: props.onToggle, location: "settings" }),
@@ -968,6 +1108,21 @@ function frame(now) {
968
1108
  var pointer = React.useRef(null);
969
1109
  var help = React.useRef(null);
970
1110
  var dismissed = React.useRef(false);
1111
+ var appearanceState = React.useState(false);
1112
+ var appearanceOpen = appearanceState[0], setAppearanceOpen = appearanceState[1];
1113
+ var appearancePanel = React.useRef(null);
1114
+ var appearanceButton = React.useRef(null);
1115
+ React.useEffect(function () {
1116
+ if (!open || !appearanceOpen) return undefined;
1117
+ var first = appearancePanel.current && appearancePanel.current.querySelector("input:checked");
1118
+ if (first) first.focus();
1119
+ function outside(event) {
1120
+ if (appearancePanel.current && !appearancePanel.current.contains(event.target)
1121
+ && appearanceButton.current && !appearanceButton.current.contains(event.target)) setAppearanceOpen(false);
1122
+ }
1123
+ window.addEventListener("pointerdown", outside);
1124
+ return function () { window.removeEventListener("pointerdown", outside); };
1125
+ }, [open, appearanceOpen]);
971
1126
  React.useEffect(function () {
972
1127
  return function () { if (configRequest.current) configRequest.current.abort(); };
973
1128
  }, []);
@@ -982,6 +1137,23 @@ function frame(now) {
982
1137
  }).finally(function () { if (!controller.signal.aborted) setConfigBusy(false); });
983
1138
  return function () { controller.abort(); };
984
1139
  }, [open]);
1140
+ function saveAppearance(changes) {
1141
+ if (configBusy || configRequest.current) return;
1142
+ if (Object.keys(changes).every(function (key) { return settings[key] === changes[key]; })) return;
1143
+ var previous = settings;
1144
+ var controller = new AbortController();
1145
+ var timer = setTimeout(function () { controller.abort(); }, 10000);
1146
+ configRequest.current = controller;
1147
+ setConfigBusy(true); setConfigError("");
1148
+ setSettings(Object.assign({}, settings, changes));
1149
+ requestApi("/memory-engine/api/config", { method: "PUT", headers: { "Content-Type": "application/json" },
1150
+ body: JSON.stringify(changes), signal: controller.signal }).then(function (data) {
1151
+ if (!controller.signal.aborted) setSettings(Object.assign({}, defaultSettings, data));
1152
+ }).catch(function (failure) {
1153
+ setSettings(previous);
1154
+ setConfigError("外观保存失败,已恢复原设置:" + (controller.signal.aborted ? "请求超时或已中断,请刷新确认状态" : failure.message));
1155
+ }).finally(function () { clearTimeout(timer); configRequest.current = null; setConfigBusy(false); });
1156
+ }
985
1157
  function saveAutoMemory(enabled) {
986
1158
  if (configRequest.current) return;
987
1159
  var previous = settings;
@@ -1027,12 +1199,13 @@ function frame(now) {
1027
1199
  dismissed.current = false;
1028
1200
  setGuideOpen(false);
1029
1201
  setSelected(null);
1202
+ setAppearanceOpen(false);
1030
1203
  setTab("map");
1031
1204
  setOpen(Date.now());
1032
1205
  }
1033
- function closeMap() { setSelected(null); setCostHint(false); setOpen(false); }
1206
+ function closeMap() { setSelected(null); setCostHint(false); setAppearanceOpen(false); setOpen(false); }
1034
1207
  function startPointer(event) {
1035
- if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls]")) { pointer.current = null; return; }
1208
+ if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls], [data-memory-engine-appearance-panel]")) { pointer.current = null; return; }
1036
1209
  pointer.current = { x: event.clientX, y: event.clientY, moved: 0, version: selectionVersion.current };
1037
1210
  }
1038
1211
  function movePointer(event) {
@@ -1065,7 +1238,12 @@ function frame(now) {
1065
1238
  }, [open, retry, settings.showGuideOnEmpty]);
1066
1239
  React.useEffect(function () {
1067
1240
  if (!open) return undefined;
1068
- function onEscape() { if (guideOpen) closeGuide(); else if (selected) setSelected(null); else closeMap(); }
1241
+ function onEscape() {
1242
+ if (guideOpen) closeGuide();
1243
+ else if (appearanceOpen) { setAppearanceOpen(false); if (appearanceButton.current) appearanceButton.current.focus(); }
1244
+ else if (selected) setSelected(null);
1245
+ else closeMap();
1246
+ }
1069
1247
  function onKey(event) {
1070
1248
  if (event.key !== "Escape") return;
1071
1249
  if (event.target && event.target.closest && event.target.closest("[data-memory-engine-confirm]")) return;
@@ -1075,7 +1253,7 @@ function frame(now) {
1075
1253
  }
1076
1254
  window.addEventListener("keydown", onKey, true);
1077
1255
  return function () { window.removeEventListener("keydown", onKey, true); };
1078
- }, [open, guideOpen, selected]);
1256
+ }, [open, guideOpen, selected, appearanceOpen]);
1079
1257
  var button = React.createElement("button", {
1080
1258
  type: "button",
1081
1259
  onClick: openMap,
@@ -1089,12 +1267,15 @@ function frame(now) {
1089
1267
  role: "dialog",
1090
1268
  "aria-modal": "true",
1091
1269
  "aria-label": "记忆图谱",
1270
+ "data-memory-engine-theme": settings.graphTheme, "data-memory-engine-layout": settings.graphLayout,
1092
1271
  onPointerDownCapture: startPointer, onPointerMoveCapture: movePointer,
1093
1272
  onPointerCancelCapture: function () { pointer.current = null; }, onClick: dismissOutside,
1094
- style: { position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c" },
1095
- }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected })
1273
+ style: Object.assign({ position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c", colorScheme: settings.graphTheme === "paper" ? "light" : "dark" }, appearanceStyles[settings.graphTheme]),
1274
+ }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected,
1275
+ graphTheme: settings.graphTheme, graphLayout: settings.graphLayout })
1096
1276
  : tab === "memories" ? React.createElement(MemoryList, { onChanged: function () { setRetry(function (value) { return value + 1; }); } })
1097
1277
  : React.createElement(MemorySettings, { settings: settings, configBusy: configBusy, onToggle: toggleAutoMemory, onUseAutoImport: useAutoImport,
1278
+ onAppearanceChange: saveAppearance,
1098
1279
  onCleared: function () { openMap(); setGuideOpen(true); } }),
1099
1280
  tab !== "map" ? React.createElement("strong", { style: { position: "absolute", top: "23px", left: "24px", font: "600 18px system-ui,sans-serif", color: "#b7e9ff" } }, "记忆管理") : null,
1100
1281
  React.createElement("style", null, "@keyframes dsh-memory-detail-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@media(prefers-reduced-motion:reduce){[data-memory-engine-detail]{animation:none!important}}"),
@@ -1104,9 +1285,11 @@ function frame(now) {
1104
1285
  }, React.createElement("div", { role: "tablist", "aria-label": "记忆面板", style: { display: "flex", gap: "4px", marginRight: "8px" } },
1105
1286
  [["map", "图谱"], ["memories", "记忆"], ["settings", "设置"]].map(function (entry) {
1106
1287
  return React.createElement("button", { key: entry[0], type: "button", role: "tab", "aria-selected": tab === entry[0], "data-memory-engine-tab": entry[0],
1107
- onClick: function () { setSelected(null); setTab(entry[0]); },
1108
- style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "#204762" : "#091422", borderColor: tab === entry[0] ? "#8acded" : "#35536b" }) }, entry[1]);
1109
- })), React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
1288
+ onClick: function () { setSelected(null); setAppearanceOpen(false); setTab(entry[0]); },
1289
+ style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "var(--memory-button,#204762)" : "var(--memory-panel,#091422)", borderColor: tab === entry[0] ? "var(--memory-accent,#8acded)" : "var(--memory-border,#35536b)" }) }, entry[1]);
1290
+ })), React.createElement("button", { ref: appearanceButton, type: "button", "data-memory-engine-appearance": "true", "aria-expanded": appearanceOpen,
1291
+ "aria-haspopup": "dialog", onClick: function () { setAppearanceOpen(!appearanceOpen); }, style: actionStyle }, "外观"),
1292
+ React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
1110
1293
  ref: help, type: "button", onClick: function () { setGuideOpen(true); },
1111
1294
  "aria-label": "查看使用指南", title: "使用指南", style: actionStyle,
1112
1295
  }, "?"), React.createElement("button", {
@@ -1114,8 +1297,15 @@ function frame(now) {
1114
1297
  onClick: closeMap,
1115
1298
  "aria-label": "关闭记忆图谱",
1116
1299
  style: actionStyle,
1117
- }, "关闭 ×")), configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
1118
- style: { position: "absolute", top: "50px", right: "18px", zIndex: 2, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
1300
+ }, "关闭 ×")), appearanceOpen ? React.createElement("section", { ref: appearancePanel, role: "dialog", "aria-label": "外观", "data-memory-engine-appearance-panel": "true",
1301
+ style: { position: "absolute", top: "62px", right: "18px", zIndex: 3, boxSizing: "border-box", width: "min(380px,calc(100vw - 36px))", maxHeight: "calc(100vh - 90px)", overflowY: "auto",
1302
+ padding: "18px", border: "1px solid var(--memory-border,#3976a0)", borderRadius: "12px", background: "var(--memory-panel,#07111f)", boxShadow: "0 12px 35px #0004" } },
1303
+ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "14px", color: "var(--memory-text,#dbeaf8)" } },
1304
+ React.createElement("strong", null, "外观"), React.createElement("button", { type: "button", "aria-label": "关闭外观", style: actionStyle,
1305
+ onClick: function () { setAppearanceOpen(false); if (appearanceButton.current) appearanceButton.current.focus(); } }, "×")),
1306
+ React.createElement(AppearanceControls, { settings: settings, busy: configBusy, onChange: saveAppearance, location: "header" })) : null,
1307
+ configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
1308
+ style: { position: "absolute", top: "50px", left: "18px", zIndex: 4, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
1119
1309
  costHint ? React.createElement(ConfirmationDialog, { title: "开启自动记忆", message: "自动记忆会在每轮对话后调用一次模型,大约每轮几分钱。可以随时在这里关掉。实际费用以模型及计费方案为准。",
1120
1310
  submitLabel: "知道了,开启", busy: configBusy, onClose: function () { setCostHint(false); }, onConfirm: function () { saveAutoMemory(true); } }) : null,
1121
1311
  selected ? React.createElement(MemoryDetails, {