dsh-memory-engine 0.7.0-beta.2b → 0.7.0-beta.6b

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/index.js CHANGED
@@ -2,6 +2,8 @@ import { MemoryStore } from './store.js';
2
2
  import { chars, titleOf } from './decay.js';
3
3
  import { generateMap, mapReport, openMap } from './map.js';
4
4
  import { registerMemoryRoutes } from './routes.js';
5
+ import { createAutoMemory } from './auto-memory.js';
6
+ import { createAutoImport } from './auto-import.js';
5
7
 
6
8
  export const name = 'memory-engine';
7
9
  export const inject = ['tools', 'webServer'];
@@ -53,14 +55,28 @@ export function createTools(defineTool, store = new MemoryStore(), { map = gener
53
55
  }, output,
54
56
  async execute(args) {
55
57
  const hits = store.recall(args);
56
- if (!hits.length) return `没有找到跟「${args.q}」相关的记忆。`;
58
+ if (!hits.length) return `【没有找到】「${args.q}」在记忆库里没有相关内容。`;
59
+ const quote = words => words.map(word => `「${word}」`).join('');
57
60
  let index = 0;
58
- return ['direct', 'spread'].map(via => {
61
+ const describe = hit => `${++index}. [${hit.kind}] ${titleOf(hit)}(留存 ${hit.retention.toFixed(2)};id=${hit.id})`;
62
+ const sections = [];
63
+ for (const via of ['direct', 'partial']) {
59
64
  const group = hits.filter(hit => hit.via === via);
60
- if (!group.length) return '';
61
- return [via === 'direct' ? '【直接命中】' : '【联想到的】', ...group.map(hit =>
62
- `${++index}. [${hit.kind}] ${titleOf(hit)}(留存 ${hit.retention.toFixed(2)};id=${hit.id}):${chars(hit.text, 120)}`)].join('\n');
63
- }).filter(Boolean).join('\n\n');
65
+ if (!group.length) continue;
66
+ const terms = group[0].terms;
67
+ const matched = terms.filter(term => group.some(hit => hit.matched.includes(term)));
68
+ const missed = terms.filter(term => !matched.includes(term));
69
+ const label = via === 'direct' ? '直接命中' : '部分命中';
70
+ const summary = `【${label} ${group.length} 条】` + (via === 'partial' ? '(覆盖不足 50%,不是直接命中)' : '')
71
+ + `命中${quote(matched)}` + (missed.length ? `;未命中${quote(missed)}` : ';全部查询词均命中');
72
+ sections.push([summary, ...group.map(hit => `${describe(hit)}\n`
73
+ + ` 覆盖 ${hit.matched.length}/${hit.terms.length};命中${quote(hit.matched)}`
74
+ + (hit.missed.length ? `;未命中${quote(hit.missed)}` : '') + `\n ${hit.snippet}`)].join('\n\n'));
75
+ }
76
+ const spread = hits.filter(hit => hit.via === 'spread');
77
+ if (spread.length) sections.push(['【联想到的(不是直接命中)】', ...spread.map(hit => `${describe(hit)}\n`
78
+ + ` 从「${hit.from.title}」(id=${hit.from.id})联想:完整标题「${hit.from.term}」出现在关联正文中。\n ${hit.snippet}`)].join('\n\n'));
79
+ return sections.join('\n\n');
64
80
  },
65
81
  }),
66
82
  defineTool({
@@ -188,6 +204,12 @@ export function apply(ctx) {
188
204
  } catch (error) {
189
205
  warn(ctx, `图谱路由注册失败,记忆工具仍可用:${error.message}`);
190
206
  }
207
+ for (const create of [createAutoMemory, createAutoImport]) {
208
+ try {
209
+ const automation = create({ ctx, store, warn: message => warn(ctx, message) });
210
+ disposers.push(() => automation.dispose());
211
+ } catch (error) { warn(ctx, `自动任务初始化失败,记忆工具仍可用:${error.message}`); }
212
+ }
191
213
  const timer = setTimeout(() => {
192
214
  try { if (store.read().length > 0) generateMap(store); }
193
215
  catch (error) { warn(ctx, `启动时图谱生成失败:${error.message}`); }
package/lib/map.js CHANGED
@@ -4,16 +4,23 @@ 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
8
 
9
- export function computeMapData(store) {
9
+ export function computeMapData(store, { warn } = {}) {
10
10
  const items = store.read().map(({ id, text, title: nodeTitle, ts, kind, heat }) => ({ id, text,
11
11
  title: nodeTitle || Array.from(text).slice(0, 20).join(''), ts, kind, heat }));
12
12
  const now = store.now();
13
+ const config = readConfig(path.dirname(path.dirname(store.file)));
14
+ const persona = readPersona(config, { now, warn });
13
15
  const scored = scoreMemories(items, now);
14
- const nodes = layoutMemories(scored.nodes);
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);
15
20
  const kinds = Object.fromEntries([...new Set(nodes.map(node => node.kind))].map(kind => [kind, nodes.filter(node => node.kind === kind).length]));
16
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,
17
24
  stats: { total: nodes.length, byKind: kinds, forgettingSoon: nodes.filter(node => node.retention < 0.3).length } };
18
25
  }
19
26
 
package/lib/noise.js ADDED
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_NOISE_PATTERNS = Object.freeze([
2
+ '重复唤醒', '已经回过', '回过了', '先不重复', '继续潜水', '潜水状态',
3
+ '标成已读', '已读水位', '先不发送', '没再发', '等他说下一句',
4
+ '看一眼情况', '没有新消息', '这轮是', '我先不', '对话停在这里',
5
+ '对话就停在这', '等有人 @', '再回来就是', '未读 0',
6
+ ]);
7
+
8
+ const REPORT_PREFIX = /^\s*(已回|已回复|已发送|已处理|已确认|已读|已标记|回过了|发过了)\s*[::]/u;
9
+
10
+ export function isNoise(text, extra = []) {
11
+ if (typeof text !== 'string') return false;
12
+ if (REPORT_PREFIX.test(Array.from(text).slice(0, 12).join(''))) return true;
13
+ const features = text.replace(/(?:他|她|对方|朋友|同事)(?:说|表示|告诉我)[\s“「『"']*(?:已经回过|回过了)/gu, '');
14
+ const patterns = new Set([...DEFAULT_NOISE_PATTERNS, ...extra].filter(pattern => typeof pattern === 'string' && pattern.length > 0));
15
+ let hits = 0;
16
+ for (const pattern of patterns) {
17
+ if (features.includes(pattern) && ++hits >= 2) return true;
18
+ }
19
+ return hits === 1 && Array.from(text).length < 150;
20
+ }
package/lib/recall.js CHANGED
@@ -1,25 +1,56 @@
1
1
  import { chars, retention, titleOf } from './decay.js';
2
2
 
3
+ export function queryTerms(query) {
4
+ return [...new Set(query.trim().toLocaleLowerCase().split(/[\s\p{P}\p{S}]+/u)
5
+ .filter(term => Array.from(term).length >= 2))];
6
+ }
7
+
3
8
  export function relevance(memory, query) {
4
- const q = query.trim().toLocaleLowerCase();
5
- if (!q) return 0;
9
+ const normalized = query.trim().toLocaleLowerCase();
6
10
  const title = titleOf(memory).toLocaleLowerCase();
7
11
  const text = memory.text.toLocaleLowerCase();
8
- const terms = q.split(/[\s\p{P}\p{S}]+/u).filter(term => Array.from(term).length >= 2);
9
- let rel = 0;
12
+ const terms = queryTerms(query);
13
+ const matched = terms.filter(term => title.includes(term) || text.includes(term));
14
+ const missed = terms.filter(term => !matched.includes(term));
15
+ let score = 0;
16
+ for (const term of terms) {
17
+ if (title.includes(term)) score += 3;
18
+ if (text.includes(term)) score += 2;
19
+ if (title.startsWith(term)) score += 2;
20
+ }
21
+ if (normalized && title.includes(normalized)) score += 4;
22
+ if (normalized && text.includes(normalized)) score += 2;
23
+ return Object.defineProperty({ score, coverage: terms.length ? matched.length / terms.length : 0, terms, matched, missed },
24
+ Symbol.toPrimitive, { value: () => score });
25
+ }
26
+
27
+ function snippet(text, terms) {
28
+ const characters = Array.from(text);
29
+ const positions = [];
10
30
  for (const term of terms) {
11
- if (title.includes(term)) rel += 3;
12
- if (text.includes(term)) rel += 2;
13
- if (title.startsWith(term)) rel += 2;
31
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ for (const match of text.matchAll(new RegExp(escaped, 'giu'))) {
33
+ const start = Array.from(text.slice(0, match.index)).length;
34
+ positions.push({ start, end: start + Array.from(match[0]).length });
35
+ }
36
+ }
37
+ positions.sort((first, second) => first.start - second.start);
38
+ let selected = chars(text, 120), bestCoverage = 0;
39
+ for (const position of positions) {
40
+ const start = Math.max(0, position.start - 45);
41
+ const end = Math.min(characters.length, position.end + 45);
42
+ const context = characters.slice(start, end).join('');
43
+ const coverage = terms.filter(term => context.toLocaleLowerCase().includes(term)).length;
44
+ if (coverage <= bestCoverage) continue;
45
+ bestCoverage = coverage;
46
+ selected = (start > 0 ? '…' : '') + context + (end < characters.length ? '…' : '');
14
47
  }
15
- if (title.includes(q)) rel += 4;
16
- if (text.includes(q)) rel += 2;
17
- return rel;
48
+ return selected;
18
49
  }
19
50
 
20
- function related(a, b) {
21
- return [a.title, a.prefix].some(key => key && b.text.includes(key))
22
- || [b.title, b.prefix].some(key => key && a.text.includes(key));
51
+ function related(first, second) {
52
+ return (Array.from(first.title).length >= 4 && second.text.includes(first.title))
53
+ || (Array.from(second.title).length >= 4 && first.text.includes(second.title));
23
54
  }
24
55
 
25
56
  export function recall(memories, { q, limit = 8, hops = 1, kind }, now = Date.now()) {
@@ -28,32 +59,40 @@ export function recall(memories, { q, limit = 8, hops = 1, kind }, now = Date.no
28
59
  hops = Math.max(0, Math.min(2, Math.trunc(Number.isFinite(hops) ? hops : 1)));
29
60
  const pool = memories.filter(memory => kind === undefined || memory.kind === kind)
30
61
  .map(memory => ({ memory, retention: retention(memory, now),
31
- title: titleOf(memory), prefix: chars(memory.text, 12), text: memory.text }));
62
+ title: titleOf(memory).toLocaleLowerCase(), text: memory.text.toLocaleLowerCase(), ...relevance(memory, q) }));
32
63
  const sorted = rows => rows.sort((a, b) => b.score - a.score || b.memory.ts - a.memory.ts);
33
- const direct = sorted(pool.map(row => ({ ...row, rel: relevance(row.memory, q), via: 'direct' }))
34
- .filter(row => row.rel > 0).map(row => ({ ...row, score: row.rel * (0.35 + 0.65 * row.retention) })))
35
- .slice(0, limit);
36
- const best = new Map(direct.map(row => [row.memory.id, row]));
64
+ const matches = sorted(pool.filter(row => row.matched.length > 0)
65
+ .map(row => ({ ...row, via: row.coverage >= 0.5 ? 'direct' : 'partial', score: row.score * (0.35 + 0.65 * row.retention) })));
66
+ const direct = matches.filter(row => row.via === 'direct').slice(0, limit);
67
+ const partial = matches.filter(row => row.via === 'partial').slice(0, limit - direct.length);
68
+ if (!direct.length && !partial.length) return [];
69
+ const spreadLimit = Math.min(limit - direct.length - partial.length, Math.floor(direct.length / 2));
70
+ const matchedIds = new Set(matches.map(row => row.memory.id));
71
+ const threshold = direct.length ? direct[0].score * 0.35 : Infinity;
72
+ const best = new Map();
37
73
  let frontier = direct;
38
- for (let hop = 0; hop < hops; hop++) {
74
+ for (let hop = 0; hop < hops && spreadLimit > 0; hop++) {
39
75
  const next = new Map();
40
- // Lower-scored paths only decay further, so they cannot enter the final
41
- // top N once their best possible next score is below its existing floor.
42
- const floor = best.size >= limit ? sorted([...best.values()])[limit - 1].score : 0;
43
76
  for (const source of frontier) {
44
- if (source.score * 0.45 < floor) continue;
77
+ const score = source.score * 0.45;
78
+ if (score < threshold) continue;
45
79
  for (const neighbor of pool) {
46
- if (neighbor.memory.id === source.memory.id || neighbor.retention <= 0.4
80
+ if (matchedIds.has(neighbor.memory.id) || neighbor.memory.id === source.memory.id || neighbor.retention <= 0.4
47
81
  || !related(source, neighbor)) continue;
48
- const score = source.score * 0.45;
49
82
  if (score <= (best.get(neighbor.memory.id)?.score ?? 0)) continue;
50
- const hit = { ...neighbor, score, via: 'spread' };
83
+ const sharedTitle = Array.from(source.title).length >= 4 && neighbor.text.includes(source.title)
84
+ ? titleOf(source.memory) : titleOf(neighbor.memory);
85
+ const hit = { ...neighbor, score, via: 'spread', from: { id: source.memory.id, title: titleOf(source.memory), term: sharedTitle } };
51
86
  best.set(neighbor.memory.id, hit);
52
87
  next.set(neighbor.memory.id, hit);
53
88
  }
54
89
  }
55
90
  frontier = [...next.values()];
56
91
  }
57
- return sorted([...best.values()]).slice(0, limit)
58
- .map(({ memory, score, retention: value, via }) => ({ ...memory, score, retention: value, via }));
92
+ return [...direct, ...partial, ...sorted([...best.values()]).slice(0, spreadLimit)]
93
+ .map(({ memory, score, retention: value, via, coverage, terms, matched, missed, from }) => ({
94
+ ...memory, score, retention: value, via, coverage, terms, matched, missed,
95
+ snippet: snippet(memory.text, via === 'spread' ? [from.term.toLocaleLowerCase()] : matched),
96
+ ...(from ? { from } : {}),
97
+ }));
59
98
  }
@@ -6,14 +6,18 @@ export function createRenderer(canvas, data, options = {}) {
6
6
  if (!ctx) throw new Error('Canvas 2D 不可用');
7
7
  const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
8
8
  const fallback = [145,205,255];
9
- let nodes = [], links = [], hubs = [];
9
+ let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
10
10
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
11
11
  const pulses = [], firing = new Map(), selections = new Set();
12
+ // 默认不画连线(数量太大会卡),只画选中光点周围的线
13
+ let selectedId=null, selectedNeighbours=new Set();
12
14
  if (typeof options.onSelect === 'function') selections.add(options.onSelect);
13
15
  let seed = 987654321;
14
16
  const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
15
17
  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 drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
18
+ let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
19
+ const frameInterval=Number.isFinite(options.frameInterval)?Math.max(0,options.frameInterval):0;
20
+ let drag=null,nextFire=0,lastTime=0,lastDrawAt=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
17
21
 
18
22
  function glowSprite(rgb) {
19
23
  const off = document.createElement('canvas');
@@ -27,11 +31,26 @@ function glowSprite(rgb) {
27
31
  return off;
28
32
  }
29
33
 
34
+ // 字符串 → 32 位无符号哈希。给连线的闪烁参数做种子:
35
+ // 用 id 而不是 Math.random,保证同一条线在每次打开时节奏一致(不会每次刷新都跳),
36
+ // 但不同连线之间互不同步。
37
+ function hashString(text) {
38
+ let hash = 2166136261;
39
+ for (let i = 0; i < text.length; i++) {
40
+ hash ^= text.charCodeAt(i);
41
+ hash = Math.imul(hash, 16777619);
42
+ }
43
+ return hash >>> 0;
44
+ }
45
+
30
46
  function setData(nextData) {
31
47
  if (destroyed) return;
48
+ spinSpeed=nextData.spinSpeed??0.35;
32
49
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
33
50
  links = nextData.links.map(link => [...link]);
34
51
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
52
+ selectedId=typeof options.selectedId==='string'?options.selectedId:null;
53
+ selectedNeighbours=new Set(selectedId?(adjacency.get(selectedId)||[]):[]);
35
54
  seed = 987654321; nextFire = 0;
36
55
  for (const node of nextData.nodes) byId.set(node.id, node);
37
56
  for (const node of nodes) adjacency.set(node.id, []);
@@ -39,6 +58,41 @@ function glowSprite(rgb) {
39
58
  if (adjacency.has(left) && adjacency.has(right)) { adjacency.get(left).push(right); adjacency.get(right).push(left); }
40
59
  }
41
60
  hubs = nodes.filter(star => star.degree > 0).sort((left,right) => right.degree-left.degree).slice(0,6);
61
+ // 每条连线一份自己的闪烁参数:相位和频率都不一样,整体才不会"齐刷刷"地一起亮。
62
+ // 用连线两端 id 的哈希做种子,保证同一条线每次打开图的节奏一致(不会跳变),
63
+ // 但线与线之间互不同步。
64
+ linkTwinkle = links.map(([left, right]) => {
65
+ const h = hashString(left + '|' + right);
66
+ return {
67
+ // 频率调低:周期从 4~18 秒放慢到 18~55 秒,变成"慢慢呼吸"而不是"闪"
68
+ freq: 0.000114 + (h % 1000) / 1000 * 0.000235,
69
+ phase: (Math.floor(h / 1000) % 6283) / 1000, // 0 ~ 6.28,彼此错开
70
+ amp: 0.05 + (Math.floor(h / 7e6) % 100) / 100 * 0.12,
71
+ // 弯曲:一半往左弯一半往右弯,弯度 8%~24%。
72
+ // 之前所有线都朝同一侧弯,视觉上像漩涡,边界区域就显空。
73
+ bow: (h % 2 === 0 ? 1 : -1) * (0.08 + (Math.floor(h / 13) % 100) / 100 * 0.16),
74
+ // 端点偏移:同一个节点被很多线连时,让它们从不同角度进来,散成扇面而不是叠成一条
75
+ jitterStart: ((Math.floor(h / 31) % 100) / 100 - 0.5) * 1.6,
76
+ jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
77
+ };
78
+ });
79
+ // 连线数量会随记忆增长爆炸(332 条时 383 根,479 条时 1738 根)。
80
+ // 每帧全画会卡,所以按"两端留存"排序,最多画 LINK_DRAW_CAP 根最亮的,
81
+ // 其余的不进这一帧(数据没丢,只是不画,缩放/点击仍按全量算)。
82
+ const LINK_DRAW_CAP = 1200;
83
+ if (links.length <= LINK_DRAW_CAP) {
84
+ visibleLinks = links.map((_, index) => index);
85
+ } else {
86
+ const byIdLocal = new Map(nodes.map(node => [node.id, node]));
87
+ const strength = index => {
88
+ const [left, right] = links[index];
89
+ const a = byIdLocal.get(left), b = byIdLocal.get(right);
90
+ return Math.min(a?.retention ?? 0, b?.retention ?? 0) + Math.min(a?.degree ?? 0, b?.degree ?? 0) * 0.002;
91
+ };
92
+ visibleLinks = links.map((_, index) => index)
93
+ .sort((x, y) => strength(y) - strength(x))
94
+ .slice(0, LINK_DRAW_CAP);
95
+ }
42
96
  for (const star of nodes) {
43
97
  star._rgb = colors[star.kind] || fallback;
44
98
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
@@ -74,9 +128,21 @@ function project(pos) {
74
128
  }
75
129
  function fire(id,now) {
76
130
  firing.set(id,1);
77
- for (const other of adjacency.get(id)||[]) {
78
- firing.set(other,Math.max(0.65,firing.get(other)||0));
79
- pulses.push({from:id,to:other,born:now,life:900+Math.random()*700});
131
+ const neighbours=adjacency.get(id)||[];
132
+ // 选中某个光点时,把它的关联线都点亮几条脉冲(最多 24 条,避免又糊成一片);
133
+ // 未选中时的随机心跳只挑 2~4 条,避免整片区域同时亮。
134
+ const selected = id===selectedId;
135
+ const limit = selected ? 24 : 2+Math.floor(Math.random()*3);
136
+ const count=Math.min(neighbours.length,limit);
137
+ const picked=new Set();
138
+ let guard=0;
139
+ while (picked.size<count && neighbours.length && guard++<400) {
140
+ picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
141
+ }
142
+ for (const other of picked) {
143
+ // 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
144
+ firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
145
+ pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
80
146
  }
81
147
  }
82
148
 
@@ -91,11 +157,14 @@ function fire(id,now) {
91
157
  if (score<bestScore) {bestScore=score;best=star;}
92
158
  }
93
159
  if (!best) return;
160
+ selectedId = best.id;
161
+ selectedNeighbours = new Set(adjacency.get(best.id) || []);
94
162
  fire(best.id,performance.now());
95
163
  for (const callback of selections) callback(byId.get(best.id));
96
164
  }
97
165
 
98
166
  function onPointerDown(event) {
167
+ resumeAt=Infinity;
99
168
  autoSpin=false; drag={x:event.clientX,y:event.clientY,moved:0,pointerId:event.pointerId};
100
169
  canvas.setPointerCapture(event.pointerId);
101
170
  }
@@ -106,6 +175,7 @@ function fire(id,now) {
106
175
  rotY+=dx*0.006; rotX=Math.max(-1.5,Math.min(1.5,rotX+dy*0.006));
107
176
  }
108
177
  function releaseDrag() {
178
+ if (drag && running) resumeAt=performance.now()+2000;
109
179
  if (drag && canvas.hasPointerCapture(drag.pointerId)) canvas.releasePointerCapture(drag.pointerId);
110
180
  drag=null;
111
181
  }
@@ -124,13 +194,27 @@ function fire(id,now) {
124
194
 
125
195
  function frame(now) {
126
196
  const delta=Math.min(50,now-lastTime||16.7); lastTime=now;
127
- if (autoSpin) rotY+=0.0011*delta/16.7;
128
- if (now>nextFire) { nextFire=now+1000+Math.random()*1500; if (hubs.length) fire(hubs[Math.floor(Math.random()*hubs.length)].id,now); }
197
+ if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
198
+ if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
199
+ if (now>nextFire) {
200
+ // 默认不画连线,所以也不撒"沿线的脉冲"——否则会出现飘来飘去的小点,找不到来源。
201
+ // 选中光点后,脉冲只在它的关联线上跑。
202
+ if (selectedId) {
203
+ fire(selectedId,now);
204
+ } else {
205
+ // 没选中时只让枢纽光点自己亮一下,不动线
206
+ if (hubs.length) {
207
+ const hub=hubs[Math.floor(Math.random()*hubs.length)];
208
+ firing.set(hub.id,1);
209
+ }
210
+ }
211
+ nextFire=now+1400+Math.random()*3200;
212
+ }
129
213
  ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03060c'; ctx.fillRect(0,0,width,height);
130
214
  ctx.strokeStyle='rgba(40,90,140,0.055)';ctx.lineWidth=1;
131
215
  for (let index=0;index<20;index++) {
132
216
  const y=index/20*height;
133
- ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.0003+index)*30,width*.7,y-Math.cos(now*.00025+index)*30,width,y);ctx.stroke();
217
+ ctx.beginPath();ctx.moveTo(0,y);ctx.bezierCurveTo(width*.3,y+Math.sin(now*.00007+index)*30,width*.7,y-Math.cos(now*.00006+index)*30,width,y);ctx.stroke();
134
218
  }
135
219
  const points=[];
136
220
  for (const star of nodes) {
@@ -138,19 +222,39 @@ function frame(now) {
138
222
  if (!p || p.x< -100 || p.x>width+100 || p.y< -100 || p.y>height+100) {star._screen=null;continue;}
139
223
  points.push({star,p});
140
224
  }
141
- points.sort((left,right)=>right.p.depth-left.p.depth);
225
+ points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
142
226
  const screen=new Map(points.map(point=>[point.star.id,point]));
143
- ctx.globalCompositeOperation='lighter';
144
- for (const [left,right] of links) {
145
- const first=screen.get(left),second=screen.get(right);
146
- if (!first||!second) continue;
147
- const act=Math.max(firing.get(left)||0,firing.get(right)||0);
148
- const alpha=Math.min(.85,.1*Math.min(first.star.retention,second.star.retention)+.35*act);
149
- if (alpha<.01) continue;
150
- const mx=(first.p.x+second.p.x)/2+(first.p.y-second.p.y)*.18;
151
- const my=(first.p.y+second.p.y)/2+(second.p.x-first.p.x)*.18;
152
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6;
153
- ctx.beginPath();ctx.moveTo(first.p.x,first.p.y);ctx.quadraticCurveTo(mx,my,second.p.x,second.p.y);ctx.stroke();
227
+ // 连线只在"选中某个光点"时画,且只画和它相连的那些。
228
+ // 全量 1738 根每帧画会卡;默认干干净净,点谁看谁的关联。
229
+ const linkPaths=[];
230
+ if (selectedId) {
231
+ for (const [left,right] of links) {
232
+ if (left!==selectedId && right!==selectedId) continue;
233
+ const first=screen.get(left),second=screen.get(right);
234
+ if (!first||!second) continue;
235
+ const target = left===selectedId ? second : first;
236
+ const ret = Math.min(first.star.retention, second.star.retention);
237
+ const alpha = Math.min(.9, .22 + ret*.35);
238
+ const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
239
+ const len=Math.hypot(dx,dy)||1;
240
+ const nx=-dy/len, ny=dx/len;
241
+ // 朝选中点那一侧弯,形成"从中心散开"的观感
242
+ const bow = (target===second ? 1 : -1) * .16;
243
+ const mx=(first.p.x+second.p.x)/2+nx*len*bow*.5;
244
+ const my=(first.p.y+second.p.y)/2+ny*len*bow*.5;
245
+ linkPaths.push([first.p.x,first.p.y,mx,my,second.p.x,second.p.y,alpha]);
246
+ }
247
+ }
248
+ if (linkPaths.length) {
249
+ ctx.strokeStyle='rgba(110,190,255,0.30)';
250
+ ctx.lineWidth=1;
251
+ ctx.beginPath();
252
+ for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
253
+ ctx.stroke();
254
+ // 更亮的一层,让关联线看得清
255
+ ctx.strokeStyle='rgba(160,220,255,0.22)';
256
+ ctx.lineWidth=2;
257
+ ctx.stroke();
154
258
  }
155
259
  for (let index=pulses.length-1;index>=0;index--) {
156
260
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -166,8 +270,25 @@ function frame(now) {
166
270
  for (const {star,p} of points) {
167
271
  const act=firing.get(star.id)||0,ret=Math.max(.05,Math.min(1.2,star.retention));
168
272
  const intensity=Math.min(1,.22+ret*.72)*(1+act*1.1);
169
- const radius=Math.max(2,star.radius*p.scale*.55)*(1+Math.sin(now*.0016+(star.pos.x+star.pos.z)*.3)*.06)*(1+act*.5);
273
+ // 每颗星自己的呼吸频率和相位:从 id 哈希来,避免全图同一个节拍
274
+ if (star._pulse===undefined) {
275
+ const h=hashString(star.id);
276
+ 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};
277
+ }
278
+ const pulse=star._pulse;
279
+ const breathe=Math.sin(now*pulse.freq+pulse.phase)*pulse.amp;
280
+ const radius=Math.max(2,star.radius*p.scale*.55)*(1+breathe)*(1+act*.5);
170
281
  const rgb=star._rgb.join(',');
282
+ if (star.isCore) {
283
+ const halo=ctx.createRadialGradient(p.x,p.y,radius*.75,p.x,p.y,radius*2.2);
284
+ halo.addColorStop(0,'rgba('+rgb+',0.12)');
285
+ halo.addColorStop(.65,'rgba('+rgb+',0.04)');
286
+ halo.addColorStop(1,'rgba('+rgb+',0)');
287
+ ctx.fillStyle=halo;ctx.beginPath();ctx.arc(p.x,p.y,radius*2.2,0,Math.PI*2);ctx.fill();
288
+ ctx.strokeStyle='rgba('+rgb+',0.38)';ctx.lineWidth=.7;ctx.setLineDash([3,5]);ctx.lineDashOffset=-now*.008;
289
+ ctx.beginPath();ctx.arc(p.x,p.y,radius*1.6,0,Math.PI*2);ctx.stroke();
290
+ ctx.setLineDash([]);ctx.lineDashOffset=0;
291
+ }
171
292
  ctx.strokeStyle='rgba('+rgb+','+Math.min(1,.3*intensity)+')';
172
293
  if (radius>2.5) for (const branch of star._dendrites) {
173
294
  const length=radius*2.2*branch.length,dx=Math.cos(branch.angle),dy=Math.sin(branch.angle);
@@ -202,7 +323,7 @@ function frame(now) {
202
323
  }
203
324
  function start() {
204
325
  if (running || destroyed) return;
205
- running=true; lastTime=0; nextFire=0;
326
+ running=true; lastTime=0; nextFire=0; autoSpin=true; resumeAt=0;
206
327
  const currentGeneration = ++generation;
207
328
  canvas.addEventListener('pointerdown',onPointerDown);
208
329
  canvas.addEventListener('pointermove',onPointerMove);
@@ -218,6 +339,13 @@ function frame(now) {
218
339
  function tick(now) {
219
340
  if (!running || destroyed || currentGeneration !== generation) return;
220
341
  rafId=null;
342
+ // 帧率上限靠 options.frameInterval 控制(默认 0 = 不限)。
343
+ // 界面里按 40fps 传;测试里不传,保持逐帧确定性。
344
+ if (frameInterval > 0 && now - lastDrawAt < frameInterval) {
345
+ rafId=window.requestAnimationFrame(tick);
346
+ return;
347
+ }
348
+ lastDrawAt = now;
221
349
  frame(now);
222
350
  if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
223
351
  }
@@ -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) {
@@ -25,6 +25,7 @@ export function renderHtml(data, title = '记忆神经网络') {
25
25
  .panel,.review{position:fixed;background:#07111fee;border:1px solid #3976a055;border-radius:14px;box-shadow:0 0 40px #1763a033;padding:15px;backdrop-filter:blur(8px)}
26
26
  .panel{display:none;top:16px;right:18px;width:min(350px,calc(100vw - 36px));max-height:78vh;overflow:auto}
27
27
  .panel h2{font-size:16px;color:#b7e9ff;margin:5px 0}.panel .meta{color:#8ba8c2;margin:7px 0}.panel .text{white-space:pre-wrap;overflow-wrap:anywhere}
28
+ .panel.core .text{max-height:52vh;overflow:auto}.copy{margin-top:12px;padding:7px 12px;border:1px solid #6999b8;border-radius:8px;background:#102236;color:#e2f4ff;font:inherit;cursor:pointer}
28
29
  .close{float:right;border:0;background:transparent;color:#a4bad0;font-size:18px;cursor:pointer}
29
30
  .review{bottom:16px;left:18px;width:min(400px,calc(100vw - 36px));max-height:32vh;overflow:auto;font-size:12px}
30
31
  .review h2{font-size:14px;color:#ffdb82;margin:0 0 8px}.row{display:flex;gap:7px;align-items:center;margin:5px 0;color:#b1c6d9}
@@ -36,9 +37,9 @@ export function renderHtml(data, title = '记忆神经网络') {
36
37
  <body>
37
38
  <canvas id="map" aria-label="记忆神经网络"></canvas>
38
39
  <div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
39
- <section class="panel" id="panel"><button class="close" id="close" aria-label="关闭">×</button><div id="kind"></div><h2 id="title"></h2><div class="meta" id="meta"></div><div class="text" id="body"></div></section>
40
+ <section class="panel" id="panel"><button class="close" id="close" aria-label="关闭">×</button><div id="kind"></div><h2 id="title"></h2><div class="meta" id="meta"></div><div class="text" id="body"></div><button class="copy" id="copy" hidden>复制全文</button></section>
40
41
  <section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
41
- <div class="tip">拖拽旋转 · 滚轮缩放 · 点击查看记忆</div>
42
+ <div class="tip">拖拽旋转 · 滚轮缩放 · 点击光点看关联</div>
42
43
  <script>
43
44
  const DATA = ${embedded};
44
45
  const canvas = document.getElementById('map');
@@ -56,10 +57,30 @@ for (const [index,item] of DATA.review.entries()) {
56
57
  }
57
58
  renderer.onSelect(node => {
58
59
  document.getElementById('panel').style.display='block';
60
+ document.getElementById('panel').className=node.isCore?'panel core':'panel';
59
61
  document.getElementById('kind').textContent=node.kind;
60
62
  document.getElementById('title').textContent=node.title;
63
+ document.getElementById('meta').hidden=!!node.isCore;
61
64
  document.getElementById('meta').textContent='重要度 '+node.heat+' · 留存 '+node.retention.toFixed(2)+' · 连接数 '+node.degree+(node.emotion.length?' · '+node.emotion.map(item=>item.tag).join('/'):'');
62
- document.getElementById('body').textContent=node.text;
65
+ const text=node.isCore?Array.from(node.text).slice(0,4000).join(''):node.text;
66
+ document.getElementById('body').textContent=text;
67
+ document.getElementById('body').scrollTop=0;
68
+ const copy=document.getElementById('copy');
69
+ copy.hidden=!node.isCore; copy.textContent='复制全文';
70
+ const copyBody=async () => {
71
+ function fallback() {
72
+ const area=document.createElement('textarea');
73
+ area.value=text; area.style.cssText='position:fixed;left:-9999px'; document.body.append(area); area.select();
74
+ try { if (!document.execCommand('copy')) throw new Error('复制失败'); } finally { area.remove(); }
75
+ }
76
+ try {
77
+ if (navigator.clipboard?.writeText) {
78
+ try { await navigator.clipboard.writeText(text); } catch { fallback(); }
79
+ } else fallback();
80
+ if (copy.onclick===copyBody) copy.textContent='已复制 ✓';
81
+ } catch { if (copy.onclick===copyBody) copy.textContent='复制失败,请重试'; }
82
+ };
83
+ copy.onclick=copyBody;
63
84
  });
64
85
  renderer.start();
65
86
  </script>