dsh-memory-engine 0.6.1 → 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/lib/config.js CHANGED
@@ -8,14 +8,47 @@ export function resolveDiaryDir({ home = defaultHome(), envDir = process.env.QQ_
8
8
  return envDir || readConfig(home).diaryDir || '';
9
9
  }
10
10
 
11
+ export function readUiConfig(home = defaultHome(), options = {}) {
12
+ const config = readConfig(home);
13
+ return { hideButton: config.hideButton === true, showGuideOnEmpty: config.showGuideOnEmpty !== false,
14
+ hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed };
15
+ }
16
+
17
+ export function readPersona(config, { now = Date.now(), warn = message => console.warn(`[dsh-memory-engine] ${message}`) } = {}) {
18
+ if (config.personaText?.trim()) return { text: config.personaText, ts: now };
19
+ if (!config.personaFile?.trim()) return null;
20
+ let descriptor;
21
+ try {
22
+ descriptor = fs.openSync(config.personaFile, 'r');
23
+ const metadata = fs.fstatSync(descriptor);
24
+ if (!metadata.isFile()) throw new Error('角色卡路径不是普通文件');
25
+ const buffer = Buffer.alloc(16003);
26
+ const length = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
27
+ const text = Array.from(buffer.subarray(0, length).toString('utf8').replace(/^\uFEFF/u, '')).slice(0, 4000).join('');
28
+ return text.trim() ? { text, ts: metadata.mtimeMs } : null;
29
+ } catch (error) {
30
+ try { warn(`读取角色卡失败,使用原布局:${error.message}`); } catch {}
31
+ return null;
32
+ } finally {
33
+ if (descriptor !== undefined) fs.closeSync(descriptor);
34
+ }
35
+ }
36
+
11
37
  export function readConfig(home = defaultHome()) {
12
38
  const file = path.join(home, 'memory-engine', 'config.json');
13
39
  try {
14
40
  const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
15
41
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('配置必须是 JSON 对象');
16
- return value;
42
+ for (const key of ['personaFile', 'personaText', 'personaTitle']) {
43
+ if (value[key] !== undefined && typeof value[key] !== 'string') throw new Error(`${key} 必须是字符串`);
44
+ }
45
+ if (value.personaText !== undefined && Array.from(value.personaText).length > 4000) throw new Error('personaText 最多 4000 字符');
46
+ if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
47
+ throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
48
+ }
49
+ return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35 };
17
50
  } catch (error) {
18
- if (error.code === 'ENOENT') return {};
51
+ if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35 };
19
52
  throw new Error(`无法读取 ${file}:${error.message}`);
20
53
  }
21
54
  }
package/lib/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { MemoryStore } from './store.js';
2
2
  import { chars, titleOf } from './decay.js';
3
3
  import { generateMap, mapReport, openMap } from './map.js';
4
- import { startMapServer } from './serve.js';
4
+ import { registerMemoryRoutes } from './routes.js';
5
5
 
6
6
  export const name = 'memory-engine';
7
- export const inject = ['tools'];
7
+ export const inject = ['tools', 'webServer'];
8
8
 
9
9
  function report(ctx, error) {
10
10
  try {
@@ -184,10 +184,9 @@ export function apply(ctx) {
184
184
  const store = new MemoryStore();
185
185
  for (const tool of createTools(defineTool, store, { warning: message => warn(ctx, message) })) disposers.push(ctx.tools.register(tool));
186
186
  try {
187
- const server = await startMapServer({ store, warn: message => warn(ctx, message) });
188
- disposers.push(() => server.close());
187
+ disposers.push(registerMemoryRoutes(ctx.webServer, { store, warn: message => warn(ctx, message) }));
189
188
  } catch (error) {
190
- warn(ctx, `图谱服务启动失败,记忆工具仍可用:${error.message}`);
189
+ warn(ctx, `图谱路由注册失败,记忆工具仍可用:${error.message}`);
191
190
  }
192
191
  const timer = setTimeout(() => {
193
192
  try { if (store.read().length > 0) generateMap(store); }
package/lib/map.js CHANGED
@@ -4,7 +4,25 @@ import { randomBytes } from 'node:crypto';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { scoreMemories, layoutMemories, renderHtml } from './render/index.mjs';
7
- import { readConfig } from './config.js';
7
+ import { readConfig, readPersona } from './config.js';
8
+
9
+ export function computeMapData(store, { warn } = {}) {
10
+ const items = store.read().map(({ id, text, title: nodeTitle, ts, kind, heat }) => ({ id, text,
11
+ title: nodeTitle || Array.from(text).slice(0, 20).join(''), ts, kind, heat }));
12
+ const now = store.now();
13
+ const config = readConfig(path.dirname(path.dirname(store.file)));
14
+ const persona = readPersona(config, { now, warn });
15
+ const scored = scoreMemories(items, now);
16
+ const core = persona ? { id: 'persona', text: persona.text, title: config.personaTitle, kind: '人格', heat: 1,
17
+ ts: persona.ts, isCore: true, retention: 1, degree: 0, emotion: [], ageDays: 0, boosts: 0,
18
+ pos: { x: 0, y: 0, z: 0 }, radius: 6.5 } : null;
19
+ const nodes = layoutMemories(core ? [core, ...scored.nodes] : scored.nodes);
20
+ const kinds = Object.fromEntries([...new Set(nodes.map(node => node.kind))].map(kind => [kind, nodes.filter(node => node.kind === kind).length]));
21
+ return { generatedAt: now, nodes, links: scored.links, review: scored.review,
22
+ core: core ? { id: core.id, title: core.title, preview: Array.from(core.text).slice(0, 120).join('') + (Array.from(core.text).length > 120 ? '…' : '') } : null,
23
+ spinSpeed: config.spinSpeed,
24
+ stats: { total: nodes.length, byKind: kinds, forgettingSoon: nodes.filter(node => node.retention < 0.3).length } };
25
+ }
8
26
 
9
27
  export function generateMap(store, { out, title = '我的记忆神经网络' } = {}) {
10
28
  const started = performance.now();
@@ -20,15 +38,8 @@ export function generateMap(store, { out, title = '我的记忆神经网络' } =
20
38
  throw new Error('桥接目录只读,请把图谱输出到插件数据目录或其他目录');
21
39
  }
22
40
  }
23
- const items = store.read().map(({ id, text, title: nodeTitle, ts, kind, heat }) => ({ id, text,
24
- title: nodeTitle || Array.from(text).slice(0, 20).join(''), ts, kind, heat }));
25
- const now = store.now();
26
- const { nodes, links, review } = scoreMemories(items, now);
27
- const stars = layoutMemories(nodes);
28
- const kinds = Object.fromEntries([...new Set(stars.map(star => star.kind))].map(kind => [kind, stars.filter(star => star.kind === kind).length]));
29
- const payload = { generatedAt: now, stats: { total: stars.length, byKind: kinds,
30
- forgettingSoon: stars.filter(star => star.retention < 0.3).length }, stars, links, review };
31
- const html = renderHtml(payload, title);
41
+ const { nodes, ...data } = computeMapData(store);
42
+ const html = renderHtml({ ...data, stars: nodes }, title);
32
43
  fs.mkdirSync(path.dirname(file), { recursive: true });
33
44
  const temporary = `${file}.${process.pid}.${randomBytes(5).toString('hex')}.tmp`;
34
45
  try {
@@ -37,7 +48,8 @@ export function generateMap(store, { out, title = '我的记忆神经网络' } =
37
48
  } finally {
38
49
  try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
39
50
  }
40
- return { file, total: stars.length, edges: links.length, kinds, bytes: Buffer.byteLength(html), elapsedMs: performance.now() - started };
51
+ return { file, total: nodes.length, edges: data.links.length, kinds: data.stats.byKind,
52
+ bytes: Buffer.byteLength(html), elapsedMs: performance.now() - started };
41
53
  }
42
54
 
43
55
  export function mapReport(result) {
package/lib/onboard.js ADDED
@@ -0,0 +1,18 @@
1
+ export const ONBOARD_PROMPT = [
2
+ '聊天时遇到这些,用 memory_save 记下来:',
3
+ ' · 对方的称呼、偏好、忌讳、习惯(kind="印象",who 填对方名字)',
4
+ ' · 答应过的事、约定、承诺(kind="对话")',
5
+ ' · 情绪特别重的瞬间(kind="事件",heat=1)',
6
+ ' · 新学到的知识、名词、梗的解释(kind="知识")',
7
+ ' · 每天结束时,用一两句写当天小结(kind="日记")',
8
+ '',
9
+ '不用每条都记。一天三五条就够,宁可少存也别灌水。',
10
+ '存之前先 memory_recall 查一下,避免重复。',
11
+ ].join('\n');
12
+
13
+ export const ONBOARD_STEPS = [
14
+ { title: '让机器人开始记', body: '把这段提示词加进机器人的角色卡或提示词里。' },
15
+ { title: '或者导入旧记忆', body: '在对话里说:「把 D:\\bot\\memories.json 导进记忆库」。支持 JSON、Markdown 和纯文本,先预览,确认后导入。' },
16
+ { title: '聊几句,回来点刷新', body: '有了新记忆后,点「刷新图谱」就能看到。' },
17
+ ];
18
+
@@ -0,0 +1,348 @@
1
+ export function createRenderer(canvas, data, options = {}) {
2
+ const document = canvas.ownerDocument;
3
+ const window = document.defaultView;
4
+ const performance = window.performance;
5
+ const ctx = canvas.getContext('2d', { alpha: false });
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];
9
+ let nodes = [], links = [], hubs = [], linkTwinkle = [];
10
+ const byId = new Map(), adjacency = new Map(), sprites = new Map();
11
+ const pulses = [], firing = new Map(), selections = new Set();
12
+ if (typeof options.onSelect === 'function') selections.add(options.onSelect);
13
+ let seed = 987654321;
14
+ const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
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;
17
+ let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
18
+
19
+ function glowSprite(rgb) {
20
+ const off = document.createElement('canvas');
21
+ off.width = off.height = 96;
22
+ const brush = off.getContext('2d');
23
+ const gradient = brush.createRadialGradient(48,48,1,48,48,48);
24
+ gradient.addColorStop(0,'rgba('+rgb.join(',')+',0.7)');
25
+ gradient.addColorStop(0.4,'rgba('+rgb.join(',')+',0.19)');
26
+ gradient.addColorStop(1,'rgba('+rgb.join(',')+',0)');
27
+ brush.fillStyle = gradient; brush.fillRect(0,0,96,96);
28
+ return off;
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
+
43
+ function setData(nextData) {
44
+ if (destroyed) return;
45
+ spinSpeed=nextData.spinSpeed??0.35;
46
+ nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
47
+ links = nextData.links.map(link => [...link]);
48
+ byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
49
+ seed = 987654321; nextFire = 0;
50
+ for (const node of nextData.nodes) byId.set(node.id, node);
51
+ for (const node of nodes) adjacency.set(node.id, []);
52
+ for (const [left,right] of links) {
53
+ if (adjacency.has(left) && adjacency.has(right)) { adjacency.get(left).push(right); adjacency.get(right).push(left); }
54
+ }
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
+ });
74
+ for (const star of nodes) {
75
+ star._rgb = colors[star.kind] || fallback;
76
+ const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
77
+ star._dendrites = Array.from({length:count},(_,index) => ({
78
+ angle:index/count*Math.PI*2+random()*0.7,
79
+ length:0.9+random()*1.5, bend:(random()-0.5)*0.9, width:0.5+random()*0.7,
80
+ fork:random()<0.55 ? (random()-0.5)*1.5 : null
81
+ }));
82
+ }
83
+ for (const star of nodes) {
84
+ const key = star._rgb.join(',');
85
+ if (!sprites.has(key)) sprites.set(key,glowSprite(star._rgb));
86
+ star._glow = sprites.get(key);
87
+ }
88
+
89
+ }
90
+
91
+ function resize() {
92
+ if (destroyed) return;
93
+ const bounds = canvas.getBoundingClientRect();
94
+ width=bounds.width; height=bounds.height; dpr=Math.min(1.5,window.devicePixelRatio||1);
95
+ canvas.width=Math.round(width*dpr); canvas.height=Math.round(height*dpr);
96
+ ctx.setTransform(dpr,0,0,dpr,0,0);
97
+ }
98
+
99
+ function project(pos) {
100
+ const cy=Math.cos(rotY),sy=Math.sin(rotY),cx=Math.cos(rotX),sx=Math.sin(rotX);
101
+ const x=pos.x*cy-pos.z*sy,z=pos.x*sy+pos.z*cy;
102
+ const y=pos.y*cx-z*sx,depth=pos.y*sx+z*cx+250;
103
+ if (depth<=12) return null;
104
+ const scale=760/depth*zoom;
105
+ return {x:width/2+x*scale,y:height/2+y*scale,scale,depth};
106
+ }
107
+ function fire(id,now) {
108
+ firing.set(id,1);
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});
122
+ }
123
+ }
124
+
125
+ function select(x,y) {
126
+ let best=null,bestScore=Infinity;
127
+ for (const star of nodes) {
128
+ if (!star._screen) continue;
129
+ const value=Math.hypot(star._screen.x-x,star._screen.y-y);
130
+ const tolerance=Math.max(14,star._screen.r*1.15);
131
+ if (value>tolerance) continue;
132
+ const score=value-star._screen.r*0.22;
133
+ if (score<bestScore) {bestScore=score;best=star;}
134
+ }
135
+ if (!best) return;
136
+ fire(best.id,performance.now());
137
+ for (const callback of selections) callback(byId.get(best.id));
138
+ }
139
+
140
+ function onPointerDown(event) {
141
+ resumeAt=Infinity;
142
+ autoSpin=false; drag={x:event.clientX,y:event.clientY,moved:0,pointerId:event.pointerId};
143
+ canvas.setPointerCapture(event.pointerId);
144
+ }
145
+ function onPointerMove(event) {
146
+ if (!drag) return;
147
+ const dx=event.clientX-drag.x,dy=event.clientY-drag.y;
148
+ drag.x=event.clientX; drag.y=event.clientY; drag.moved+=Math.abs(dx)+Math.abs(dy);
149
+ rotY+=dx*0.006; rotX=Math.max(-1.5,Math.min(1.5,rotX+dy*0.006));
150
+ }
151
+ function releaseDrag() {
152
+ if (drag && running) resumeAt=performance.now()+2000;
153
+ if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId);
154
+ drag=null;
155
+ }
156
+ function onPointerUp(event) {
157
+ const clicked = drag && drag.moved<6;
158
+ releaseDrag();
159
+ if (clicked) {
160
+ const bounds = canvas.getBoundingClientRect();
161
+ select(event.clientX-bounds.left,event.clientY-bounds.top);
162
+ }
163
+ }
164
+ function onWheel(event) {
165
+ event.preventDefault();
166
+ zoom=Math.max(0.3,Math.min(4.5,zoom*(event.deltaY>0?0.92:1.08)));
167
+ }
168
+
169
+ function frame(now) {
170
+ const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
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
+ }
181
+ ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
182
+ ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
183
+ for (let index=0;index<20;index++) {
184
+ const y=index/20*height;
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();
186
+ }
187
+ const points=[];
188
+ for (const star of nodes) {
189
+ const p=project(star.pos);
190
+ if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
191
+ points.push({star,p});
192
+ }
193
+ points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
194
+ const screen=new Map(points.map(point=>[point.star.id,point]));
195
+ ctx.globalCompositeOperation='lighter';
196
+ for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
197
+ const [left,right]=links[linkIndex];
198
+ const first=screen.get(left),second=screen.get(right);
199
+ if (!first||!second) continue;
200
+ const act=Math.max(firing.get(left)||0,firing.get(right)||0);
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);
209
+ if (alpha<.01) continue;
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();
226
+ }
227
+ for (let index=pulses.length-1;index>=0;index--) {
228
+ const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
229
+ if (fraction>=1) {pulses.splice(index,1);continue;}
230
+ const first=screen.get(pulse.from),second=screen.get(pulse.to);
231
+ if (!first||!second) continue;
232
+ const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18,my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
233
+ const inverse=1-fraction;
234
+ const x=inverse*inverse*first.p.x+2*inverse*fraction*mx+fraction*fraction*second.p.x;
235
+ const y=inverse*inverse*first.p.y+2*inverse*fraction*my+fraction*fraction*second.p.y;
236
+ ctx.fillStyle='rgba(205,245,255,'+(.9*inverse)+')';ctx.beginPath();ctx.arc(x,y,2.5+2*inverse,0,Math.PI*2);ctx.fill();
237
+ }
238
+ for (const {star,p} of points) {
239
+ const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
240
+ const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
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);
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
+ }
260
+ ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
261
+ if (radius>2.5) for (const branch of star._dendrites) {
262
+ const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
263
+ const ex=p.x+dx*length,ey=p.y+dy*length;
264
+ const cx=p.x+dx*length*.55-dy*length*branch.bend*.5;
265
+ const cy=p.y+dy*length*.55+dx*length*branch.bend*.5;
266
+ ctx.lineWidth=branch.width;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.quadraticCurveTo(cx,cy,ex,ey);ctx.stroke();
267
+ if (branch.fork!==null) {
268
+ const angle=branch.angle+branch.fork;
269
+ ctx.lineWidth=branch.width*.65;ctx.beginPath();ctx.moveTo(cx,cy);ctx.quadraticCurveTo(cx+Math.cos(angle)*length*.2,cy+Math.sin(angle)*length*.2,cx+Math.cos(angle)*length*.34,cy+Math.sin(angle)*length*.34);ctx.stroke();
270
+ }
271
+ }
272
+ const glowRadius=radius*3.6;
273
+ 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;
274
+ ctx.fillStyle='rgba('+rgb+','+Math.min(1,.42*intensity)+')';ctx.beginPath();ctx.arc(p.x,p.y,radius,0,Math.PI*2);ctx.fill();
275
+ ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.75*intensity)+')';ctx.lineWidth=1;ctx.stroke();
276
+ 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();
277
+ if (star.retention<.25) {
278
+ ctx.strokeStyle='rgba(255,110,110,'+Math.max(0,.45-star.retention)+')';ctx.setLineDash([3,4]);
279
+ ctx.beginPath();ctx.arc(p.x,p.y,radius*3.2,0,Math.PI*2);ctx.stroke();ctx.setLineDash([]);
280
+ }
281
+ star._screen={x:p.x,y:p.y,r:Math.max(8,radius*2.6)};
282
+ }
283
+ for (const [id,value] of firing) {
284
+ const next=value*Math.pow(.965,delta/16.7);
285
+ if (next<.02) firing.delete(id); else firing.set(id,next);
286
+ }
287
+ ctx.globalCompositeOperation='source-over';
288
+ const vignette=ctx.createRadialGradient(width/2,height/2,Math.min(width,height)*.34,width/2,height/2,Math.max(width,height)*.8);
289
+ vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.78)');
290
+ ctx.fillStyle=vignette;ctx.fillRect(0,0,width,height);
291
+ }
292
+ function start() {
293
+ if (running || destroyed) return;
294
+ running=true; lastTime=0; nextFire=0; autoSpin=true; resumeAt=0;
295
+ const currentGeneration = ++generation;
296
+ canvas.addEventListener('pointerdown',onPointerDown);
297
+ canvas.addEventListener('pointermove',onPointerMove);
298
+ canvas.addEventListener('pointerup',onPointerUp);
299
+ canvas.addEventListener('pointercancel',releaseDrag);
300
+ canvas.addEventListener('wheel',onWheel,{passive:false});
301
+ window.addEventListener('resize',resize);
302
+ if (window.ResizeObserver) {
303
+ observer = new window.ResizeObserver(resize);
304
+ observer.observe(canvas);
305
+ }
306
+ resize();
307
+ function tick(now) {
308
+ if (!running || destroyed || currentGeneration !== generation) return;
309
+ rafId=null;
310
+ frame(now);
311
+ if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
312
+ }
313
+ rafId=window.requestAnimationFrame(tick);
314
+ }
315
+
316
+ function stop() {
317
+ running=false; generation++;
318
+ if (rafId !== null) window.cancelAnimationFrame(rafId);
319
+ rafId=null;
320
+ observer?.disconnect(); observer=null;
321
+ canvas.removeEventListener('pointerdown',onPointerDown);
322
+ canvas.removeEventListener('pointermove',onPointerMove);
323
+ canvas.removeEventListener('pointerup',onPointerUp);
324
+ canvas.removeEventListener('pointercancel',releaseDrag);
325
+ canvas.removeEventListener('wheel',onWheel);
326
+ window.removeEventListener('resize',resize);
327
+ releaseDrag();
328
+ pulses.length=0; firing.clear(); lastTime=0; nextFire=0;
329
+ }
330
+
331
+ function destroy() {
332
+ if (destroyed) return;
333
+ stop(); destroyed=true;
334
+ selections.clear(); byId.clear(); adjacency.clear(); sprites.clear();
335
+ nodes=[]; links=[]; hubs=[];
336
+ }
337
+
338
+ setData(data);
339
+ return {
340
+ start, stop, resize, setData, destroy,
341
+ fireNeuron(id) { if (!destroyed && byId.has(id)) fire(id,performance.now()); },
342
+ onSelect(callback) {
343
+ if (!destroyed) selections.add(callback);
344
+ return () => selections.delete(callback);
345
+ },
346
+ };
347
+ }
348
+
@@ -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) {