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/README.md +91 -34
- package/lib/auto-import.js +227 -0
- package/lib/auto-log.js +30 -0
- package/lib/auto-memory.js +156 -0
- package/lib/auto-sources.js +106 -0
- package/lib/automation-config.js +66 -0
- package/lib/automation-state.js +43 -0
- package/lib/classify.js +11 -0
- package/lib/client.js +476 -39
- package/lib/client.src.js +326 -17
- package/lib/config.js +38 -5
- package/lib/dedupe.js +2 -2
- package/lib/file-import.js +4 -3
- package/lib/import.js +8 -5
- package/lib/index.js +28 -6
- package/lib/map.js +10 -3
- package/lib/noise.js +20 -0
- package/lib/recall.js +67 -28
- package/lib/render/canvas.js +150 -22
- package/lib/render/layout.mjs +43 -0
- package/lib/render/render.mjs +24 -3
- package/lib/routes.js +93 -8
- package/lib/session-text.js +44 -0
- package/lib/store.js +49 -4
- package/package.json +1 -1
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
|
|
58
|
+
if (!hits.length) return `【没有找到】「${args.q}」在记忆库里没有相关内容。`;
|
|
59
|
+
const quote = words => words.map(word => `「${word}」`).join('');
|
|
57
60
|
let index = 0;
|
|
58
|
-
|
|
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)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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 =
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
if (text.includes(q)) rel += 2;
|
|
17
|
-
return rel;
|
|
48
|
+
return selected;
|
|
18
49
|
}
|
|
19
50
|
|
|
20
|
-
function related(
|
|
21
|
-
return
|
|
22
|
-
||
|
|
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),
|
|
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
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
const
|
|
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
|
-
|
|
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
|
|
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,
|
|
58
|
-
.map(({ 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
|
}
|
package/lib/render/canvas.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
|
128
|
-
if (
|
|
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*.
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/render/layout.mjs
CHANGED
|
@@ -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) {
|
package/lib/render/render.mjs
CHANGED
|
@@ -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">拖拽旋转 · 滚轮缩放 ·
|
|
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
|
-
|
|
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>
|