dsh-memory-engine 0.7.0-beta.4 → 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 +49 -25
- 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 +83 -44
- package/lib/client.src.js +2 -2
- package/lib/config.js +9 -4
- 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/noise.js +20 -0
- package/lib/recall.js +67 -28
- package/lib/render/canvas.js +81 -42
- package/lib/render/render.mjs +1 -1
- package/lib/session-text.js +44 -0
- package/lib/store.js +2 -2
- 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/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,15 +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 = [], linkTwinkle = [];
|
|
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
18
|
let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
|
|
17
|
-
|
|
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;
|
|
18
21
|
|
|
19
22
|
function glowSprite(rgb) {
|
|
20
23
|
const off = document.createElement('canvas');
|
|
@@ -46,6 +49,8 @@ function glowSprite(rgb) {
|
|
|
46
49
|
nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
|
|
47
50
|
links = nextData.links.map(link => [...link]);
|
|
48
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)||[]):[]);
|
|
49
54
|
seed = 987654321; nextFire = 0;
|
|
50
55
|
for (const node of nextData.nodes) byId.set(node.id, node);
|
|
51
56
|
for (const node of nodes) adjacency.set(node.id, []);
|
|
@@ -71,6 +76,23 @@ function glowSprite(rgb) {
|
|
|
71
76
|
jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
|
|
72
77
|
};
|
|
73
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
|
+
}
|
|
74
96
|
for (const star of nodes) {
|
|
75
97
|
star._rgb = colors[star.kind] || fallback;
|
|
76
98
|
const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
|
|
@@ -107,17 +129,19 @@ function project(pos) {
|
|
|
107
129
|
function fire(id,now) {
|
|
108
130
|
firing.set(id,1);
|
|
109
131
|
const neighbours=adjacency.get(id)||[];
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const
|
|
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);
|
|
113
137
|
const picked=new Set();
|
|
114
138
|
let guard=0;
|
|
115
|
-
while (picked.size<count && neighbours.length && guard++<
|
|
139
|
+
while (picked.size<count && neighbours.length && guard++<400) {
|
|
116
140
|
picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
|
|
117
141
|
}
|
|
118
142
|
for (const other of picked) {
|
|
119
143
|
// 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
|
|
120
|
-
firing.set(other,Math.max(0.26,firing.get(other)||0));
|
|
144
|
+
firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
|
|
121
145
|
pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
|
|
122
146
|
}
|
|
123
147
|
}
|
|
@@ -133,6 +157,8 @@ function fire(id,now) {
|
|
|
133
157
|
if (score<bestScore) {bestScore=score;best=star;}
|
|
134
158
|
}
|
|
135
159
|
if (!best) return;
|
|
160
|
+
selectedId = best.id;
|
|
161
|
+
selectedNeighbours = new Set(adjacency.get(best.id) || []);
|
|
136
162
|
fire(best.id,performance.now());
|
|
137
163
|
for (const callback of selections) callback(byId.get(best.id));
|
|
138
164
|
}
|
|
@@ -171,10 +197,16 @@ function frame(now) {
|
|
|
171
197
|
if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
|
|
172
198
|
if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
|
|
173
199
|
if (now>nextFire) {
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
fire(
|
|
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
|
+
}
|
|
178
210
|
}
|
|
179
211
|
nextFire=now+1400+Math.random()*3200;
|
|
180
212
|
}
|
|
@@ -192,37 +224,37 @@ function frame(now) {
|
|
|
192
224
|
}
|
|
193
225
|
points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
|
|
194
226
|
const screen=new Map(points.map(point=>[point.star.id,point]));
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ctx.
|
|
225
|
-
ctx.
|
|
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();
|
|
226
258
|
}
|
|
227
259
|
for (let index=pulses.length-1;index>=0;index--) {
|
|
228
260
|
const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
|
|
@@ -307,6 +339,13 @@ function frame(now) {
|
|
|
307
339
|
function tick(now) {
|
|
308
340
|
if (!running || destroyed || currentGeneration !== generation) return;
|
|
309
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;
|
|
310
349
|
frame(now);
|
|
311
350
|
if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
|
|
312
351
|
}
|
package/lib/render/render.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export function renderHtml(data, title = '记忆神经网络') {
|
|
|
39
39
|
<div class="hud"><strong>${safeTitle}</strong><div><span id="total"></span> 个神经元 · <span id="linkCount"></span> 条突触</div><div class="sub">大小=重要度和连接数 · 亮度=留存</div></div>
|
|
40
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>
|
|
41
41
|
<section class="review"><h2>复习清单 · 留存最低的记忆</h2><div id="reviewList"></div></section>
|
|
42
|
-
<div class="tip">拖拽旋转 · 滚轮缩放 ·
|
|
42
|
+
<div class="tip">拖拽旋转 · 滚轮缩放 · 点击光点看关联</div>
|
|
43
43
|
<script>
|
|
44
44
|
const DATA = ${embedded};
|
|
45
45
|
const canvas = document.getElementById('map');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function eventText(event) {
|
|
2
|
+
let content;
|
|
3
|
+
if (event?.type === 'user/message' && (!event.data?.role || event.data.role === 'user')) content = event.data?.content;
|
|
4
|
+
if (event?.type === 'assistant/message' && (!event.data?.message?.role || event.data.message.role === 'assistant')) content = event.data?.message?.content;
|
|
5
|
+
if (!Array.isArray(content)) return '';
|
|
6
|
+
return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n').trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function usefulText(text) {
|
|
10
|
+
return Array.from(text).length > 1 && !/^(?:嗯|哦|噢|啊|好|好的|好呀|好哒|行|哈哈|呵呵|嘿嘿|谢谢|收到|ok|[\s,。!!~~])+$/iu.test(text)
|
|
11
|
+
&& text.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/gu, '').trim().length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createTurnCollector(onTurn) {
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
return {
|
|
17
|
+
accept(session, event) {
|
|
18
|
+
const id = session?.id;
|
|
19
|
+
if (typeof id !== 'string' || !event) return;
|
|
20
|
+
if (!['turn/start', 'turn/end', 'user/message', 'assistant/message'].includes(event.type)) return;
|
|
21
|
+
let state = sessions.get(id);
|
|
22
|
+
if (event.type === 'turn/start' && state?.turn !== undefined && state.turn !== event.data?.turn) state = undefined;
|
|
23
|
+
if (!state) {
|
|
24
|
+
state = { text: '', userText: '', turn: undefined };
|
|
25
|
+
sessions.set(id, state);
|
|
26
|
+
if (sessions.size > 256) sessions.delete(sessions.keys().next().value);
|
|
27
|
+
}
|
|
28
|
+
if (event.type === 'turn/start') state.turn = event.data?.turn;
|
|
29
|
+
if (event.type === 'assistant/message' && state.turn !== undefined && event.data?.turn !== state.turn) return;
|
|
30
|
+
const text = eventText(event);
|
|
31
|
+
if (text) {
|
|
32
|
+
const role = event.type === 'user/message' ? 'user' : 'assistant';
|
|
33
|
+
state.text = Array.from(`${state.text}\n${role}: ${text}`).slice(-4000).join('');
|
|
34
|
+
if (role === 'user') state.userText = Array.from(`${state.userText}\n${text}`).slice(-4000).join('');
|
|
35
|
+
}
|
|
36
|
+
if (event.type === 'turn/end') {
|
|
37
|
+
if (state.turn !== undefined && event.data?.turn !== state.turn) return;
|
|
38
|
+
sessions.delete(id);
|
|
39
|
+
if (state.userText.trim()) onTurn({ sessionId: id, text: state.text, userText: state.userText, time: event.time });
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
clear() { sessions.clear(); },
|
|
43
|
+
};
|
|
44
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -55,7 +55,7 @@ export class MemoryStore {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
save({ text, kind = '对话', heat, who, ts = this.now() }) {
|
|
58
|
+
save({ text, kind = '对话', heat, who, ts = this.now() }, { threshold = 0.7 } = {}) {
|
|
59
59
|
if (!nonempty(text) || Array.from(text).length > 2000) throw new Error('text 必须为 1–2000 字');
|
|
60
60
|
if (!nonempty(kind)) throw new Error('kind 必须是非空字符串');
|
|
61
61
|
if (heat !== undefined && ![1, 2, 3].includes(heat)) throw new Error('heat 只能为 1/2/3');
|
|
@@ -67,7 +67,7 @@ export class MemoryStore {
|
|
|
67
67
|
let memory = { id: `m-${now}-${randomBytes(3).toString('hex')}`, text, kind, ts,
|
|
68
68
|
heat: heat ?? inferred.heat, emotion: inferred.emotion,
|
|
69
69
|
...(who !== undefined ? { who } : {}), boosts: 0, createdAt: now };
|
|
70
|
-
const match = findDuplicate(rows, memory);
|
|
70
|
+
const match = findDuplicate(rows, memory, undefined, threshold);
|
|
71
71
|
if (match) {
|
|
72
72
|
// A repeated save is one reinforcement; batch merges simply sum counts.
|
|
73
73
|
memory.boosts = 1;
|