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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { collectBridge } from './import.js';
|
|
4
|
+
import { collectFile } from './file-import.js';
|
|
5
|
+
|
|
6
|
+
function inside(root, file) {
|
|
7
|
+
const relative = path.relative(root, file);
|
|
8
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sourceFiles(source, warn) {
|
|
12
|
+
if (!path.isAbsolute(source.path)) throw new Error('自动导入 path 必须是绝对路径');
|
|
13
|
+
const requested = path.resolve(source.path);
|
|
14
|
+
const root = await fs.realpath(requested);
|
|
15
|
+
if (root !== requested) throw new Error('自动导入根路径包含软链接,请直接配置真实路径');
|
|
16
|
+
const info = await fs.stat(root);
|
|
17
|
+
const files = [];
|
|
18
|
+
async function checked(file) {
|
|
19
|
+
const real = await fs.realpath(file);
|
|
20
|
+
if (!inside(root, real)) throw new Error(`源文件软链接超出配置目录:${file}`);
|
|
21
|
+
return real;
|
|
22
|
+
}
|
|
23
|
+
async function walk(directory, extension, recursive) {
|
|
24
|
+
const real = await checked(directory);
|
|
25
|
+
for (const entry of (await fs.readdir(real, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
26
|
+
const file = path.join(real, entry.name);
|
|
27
|
+
try {
|
|
28
|
+
const resolved = await checked(file);
|
|
29
|
+
if (entry.isSymbolicLink()) continue;
|
|
30
|
+
if (entry.isDirectory() && recursive) await walk(resolved, extension, true);
|
|
31
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(extension) && (source.type !== 'diary' || !entry.name.startsWith('_'))) files.push(resolved);
|
|
32
|
+
} catch (error) { warn(error.message, { once: true }); }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (['json', 'text'].includes(source.type)) {
|
|
36
|
+
if (!info.isFile()) throw new Error(`${source.type} 必须是文件`);
|
|
37
|
+
return { root, files: [root] };
|
|
38
|
+
}
|
|
39
|
+
if (!info.isDirectory()) throw new Error(`${source.type} 必须是目录`);
|
|
40
|
+
if (source.type === 'bridge') {
|
|
41
|
+
for (const name of ['state/knowledge.json', 'state/social-v2.json', 'state/starmap/stars.json', 'state/slang.json']) {
|
|
42
|
+
try { const file = await checked(path.join(root, name)); if ((await fs.stat(file)).isFile()) files.push(file); }
|
|
43
|
+
catch (error) { warn(`略过 ${path.join(root, name)}:${error.message}`, { once: true }); }
|
|
44
|
+
}
|
|
45
|
+
try { await walk(path.join(root, 'state/history'), '.jsonl', false); }
|
|
46
|
+
catch (error) { warn(`略过 history:${error.message}`, { once: true }); }
|
|
47
|
+
} else await walk(root, '.md', source.type === 'markdown');
|
|
48
|
+
return { root, files };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function verifySource(root, file) {
|
|
52
|
+
const realRoot = await fs.realpath(root);
|
|
53
|
+
const real = await fs.realpath(file);
|
|
54
|
+
if (realRoot !== root || !inside(root, real) || real !== file) throw new Error(`源路径发生变化,已略过:${file}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function readJsonl(file, previous, info) {
|
|
58
|
+
const reset = !previous || info.size < (previous.bytes ?? 0) || (info.size === previous.size && info.mtimeMs !== previous.mtime);
|
|
59
|
+
const base = reset ? {} : previous;
|
|
60
|
+
const start = base.bytes ?? 0;
|
|
61
|
+
const end = base.pending?.bytes ?? info.size;
|
|
62
|
+
const descriptor = await fs.open(file, 'r');
|
|
63
|
+
let buffer;
|
|
64
|
+
try {
|
|
65
|
+
buffer = Buffer.alloc(Math.max(0, end - start));
|
|
66
|
+
let offset = 0;
|
|
67
|
+
while (offset < buffer.length) {
|
|
68
|
+
const { bytesRead } = await descriptor.read(buffer, offset, buffer.length - offset, start + offset);
|
|
69
|
+
if (!bytesRead) throw new Error('读取期间源文件被截短');
|
|
70
|
+
offset += bytesRead;
|
|
71
|
+
}
|
|
72
|
+
} finally { await descriptor.close(); }
|
|
73
|
+
let content = buffer.toString('utf8');
|
|
74
|
+
const finalBreak = content.lastIndexOf('\n');
|
|
75
|
+
const tail = content.slice(finalBreak + 1);
|
|
76
|
+
if (tail.trim()) {
|
|
77
|
+
try { JSON.parse(tail); }
|
|
78
|
+
catch { content = content.slice(0, finalBreak + 1); }
|
|
79
|
+
}
|
|
80
|
+
const lines = content ? content.split('\n').length - (content.endsWith('\n') ? 1 : 0) : 0;
|
|
81
|
+
return { content, bytes: start + Buffer.byteLength(content), lines: (base.lines ?? 0) + lines,
|
|
82
|
+
offset: base.pending?.offset ?? 0, base: { bytes: start, lines: base.lines ?? 0 } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function parseSourceFile(source, root, file, info, content, now) {
|
|
86
|
+
if (!['bridge', 'diary'].includes(source.type)) {
|
|
87
|
+
const collection = await collectFile({ source: source.type, path: source.type === 'markdown' ? root : file,
|
|
88
|
+
...(source.type === 'markdown' ? { files: [file] } : {}) }, now);
|
|
89
|
+
return collection.entries.map(entry => ({ ...entry, origin: { source: source.type, file } }));
|
|
90
|
+
}
|
|
91
|
+
const isDiary = path.extname(file).toLowerCase() === '.md';
|
|
92
|
+
const selected = path.resolve(file);
|
|
93
|
+
const body = content ?? await fs.readFile(selected, 'utf8');
|
|
94
|
+
if (path.extname(file) === '.json') JSON.parse(body.replace(/^\uFEFF/u, ''));
|
|
95
|
+
const collection = collectBridge({ root, diaryDir: isDiary ? path.dirname(selected) : '', io: {
|
|
96
|
+
readFileSync(input) {
|
|
97
|
+
if (path.resolve(input) === selected) return body;
|
|
98
|
+
return /(?:knowledge|slang)\.json$/u.test(input) ? '[]' : '{}';
|
|
99
|
+
},
|
|
100
|
+
readdirSync(directory) {
|
|
101
|
+
return path.resolve(directory) === path.dirname(selected) ? [{ name: path.basename(selected), isFile: () => true }] : [];
|
|
102
|
+
},
|
|
103
|
+
statSync() { return info; },
|
|
104
|
+
} });
|
|
105
|
+
return collection.entries.map(entry => ({ ...entry, origin: { source: source.type, file } }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const memoryDefaults = Object.freeze({ enabled: true, provider: 'deepseek-account', model: 'deepseek-flash',
|
|
2
|
+
maxPerTurn: 2, minChars: 12, dailyCap: 40, cooldownSec: 30,
|
|
3
|
+
excludePatterns: Object.freeze(['密码', '身份证', '银行卡', '手机号', '验证码']) });
|
|
4
|
+
|
|
5
|
+
function object(value, name) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} 必须是对象`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function enabled(value) {
|
|
10
|
+
if (typeof value !== 'boolean') throw new Error('enabled 必须是布尔值');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function range(value, name, minimum, maximum) {
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`${name} 必须是 ${minimum}~${maximum} 的整数`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function automationConfig(config = {}, warn = () => {}) {
|
|
18
|
+
let autoMemory = { ...memoryDefaults, excludePatterns: [...memoryDefaults.excludePatterns] };
|
|
19
|
+
const sources = [{ type: 'dsh-sessions' }];
|
|
20
|
+
if (config.bridgeRoot) sources.push({ type: 'bridge', path: config.bridgeRoot });
|
|
21
|
+
if (config.diaryDir) sources.push({ type: 'diary', path: config.diaryDir });
|
|
22
|
+
let autoImport = { enabled: true, importAssistant: false, noisePatterns: [], rescanAll: false,
|
|
23
|
+
sources, intervalMinutes: 60, maxPerRun: 200 };
|
|
24
|
+
try {
|
|
25
|
+
if (config.autoMemory !== undefined) object(config.autoMemory, 'autoMemory');
|
|
26
|
+
autoMemory = { ...autoMemory, ...config.autoMemory };
|
|
27
|
+
enabled(autoMemory.enabled);
|
|
28
|
+
for (const key of ['provider', 'model']) {
|
|
29
|
+
if (typeof autoMemory[key] !== 'string' || !autoMemory[key].trim()) throw new Error(`${key} 不能为空`);
|
|
30
|
+
autoMemory[key] = autoMemory[key].trim();
|
|
31
|
+
}
|
|
32
|
+
range(autoMemory.maxPerTurn, 'maxPerTurn', 1, 5);
|
|
33
|
+
range(autoMemory.minChars, 'minChars', 4, 200);
|
|
34
|
+
range(autoMemory.dailyCap, 'dailyCap', 0, 500);
|
|
35
|
+
range(autoMemory.cooldownSec, 'cooldownSec', 0, 3600);
|
|
36
|
+
if (!Array.isArray(autoMemory.excludePatterns) || autoMemory.excludePatterns.length > 20
|
|
37
|
+
|| autoMemory.excludePatterns.some(pattern => typeof pattern !== 'string' || !pattern.trim())) throw new Error('excludePatterns 必须是最多 20 个非空字符串');
|
|
38
|
+
} catch (error) {
|
|
39
|
+
warn(`自动记忆配置无效,已停用:${error.message}`);
|
|
40
|
+
autoMemory = { ...memoryDefaults, enabled: false };
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (config.autoImport !== undefined) object(config.autoImport, 'autoImport');
|
|
44
|
+
autoImport = { ...autoImport, ...config.autoImport };
|
|
45
|
+
enabled(autoImport.enabled);
|
|
46
|
+
if (typeof autoImport.importAssistant !== 'boolean') throw new Error('importAssistant 必须是布尔值');
|
|
47
|
+
if (typeof autoImport.rescanAll !== 'boolean') throw new Error('rescanAll 必须是布尔值');
|
|
48
|
+
if (autoImport.rescanFrom !== undefined && (typeof autoImport.rescanFrom !== 'string' || !autoImport.rescanFrom.trim())) throw new Error('rescanFrom 必须是非空 sessionId');
|
|
49
|
+
if (!Array.isArray(autoImport.noisePatterns) || autoImport.noisePatterns.some(pattern => typeof pattern !== 'string' || !pattern.trim())) throw new Error('noisePatterns 必须是非空字符串数组');
|
|
50
|
+
range(autoImport.intervalMinutes, 'intervalMinutes', 1, 10080);
|
|
51
|
+
range(autoImport.maxPerRun, 'maxPerRun', 1, 10000);
|
|
52
|
+
if (!Array.isArray(autoImport.sources)) throw new Error('sources 必须是数组');
|
|
53
|
+
autoImport.sources = autoImport.sources.map(source => {
|
|
54
|
+
object(source, 'source');
|
|
55
|
+
if (!['dsh-sessions', 'bridge', 'diary', 'json', 'markdown', 'text'].includes(source.type)) throw new Error(`未知来源 ${source.type}`);
|
|
56
|
+
if (source.type !== 'dsh-sessions' && (typeof source.path !== 'string' || !source.path.trim())) throw new Error(`${source.type} 缺少 path`);
|
|
57
|
+
return source.type === 'dsh-sessions' ? { type: source.type } : { type: source.type, path: source.path };
|
|
58
|
+
});
|
|
59
|
+
autoImport.sources.sort((left, right) => Number(right.type === 'dsh-sessions') - Number(left.type === 'dsh-sessions'));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
warn(`自动导入配置无效,已停用:${error.message}`);
|
|
62
|
+
autoImport = { enabled: false, importAssistant: false, noisePatterns: [], rescanAll: false,
|
|
63
|
+
sources: [], intervalMinutes: 60, maxPerRun: 200 };
|
|
64
|
+
}
|
|
65
|
+
return { autoMemory, autoImport };
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export function readState(file, fallback) {
|
|
6
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, '')); }
|
|
7
|
+
catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function writeState(file, value) {
|
|
11
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
12
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
13
|
+
try {
|
|
14
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
15
|
+
fs.renameSync(temporary, file);
|
|
16
|
+
} finally {
|
|
17
|
+
try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function localDay(time) {
|
|
22
|
+
const date = new Date(time);
|
|
23
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function warningLogger(warn, now = Date.now) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const times = new Map();
|
|
29
|
+
return (message, { once = false, key = 'runtime' } = {}) => {
|
|
30
|
+
if (once ? seen.has(message) : now() - (times.get(key) ?? -Infinity) < 60000) return;
|
|
31
|
+
if (once) seen.add(message); else times.set(key, now());
|
|
32
|
+
try { warn(message); } catch {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function service(ctx, name) {
|
|
37
|
+
try { return ctx?.get?.(name); } catch { return undefined; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function backupForAutomation(store, warn) {
|
|
41
|
+
try { store.backup(); }
|
|
42
|
+
catch (error) { warn(`自动记忆备份失败,继续写库:${error.message}`, { key: 'backup' }); }
|
|
43
|
+
}
|
package/lib/classify.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function classifyImport(text, sourceHint = '') {
|
|
2
|
+
const file = typeof sourceHint === 'string' ? sourceHint : sourceHint?.file ?? sourceHint?.path ?? '';
|
|
3
|
+
const value = typeof text === 'string' ? text.trim() : '';
|
|
4
|
+
if (/(?:^|[/\\])\d{4}-\d{2}-\d{2}\.md$/iu.test(file)) return { kind: '日记', heat: 2 };
|
|
5
|
+
if (/价格|多少钱|几块|块一杯|元/u.test(value)) return { kind: '知识', heat: 2 };
|
|
6
|
+
if (/^(?:是什么|怎么|为什么|意思是)/u.test(value)) return { kind: '知识', heat: 2 };
|
|
7
|
+
if (/过敏|不吃|不喜欢|怕|讨厌|最爱/u.test(value)) return { kind: '印象', heat: 1 };
|
|
8
|
+
if (/答应|约好|下次|明天|说好/u.test(value)) return { kind: '事件', heat: 2 };
|
|
9
|
+
if (value.startsWith('#') && /\d{4}(?:[-/.]\d{1,2}[-/.]\d{1,2}|年\d{1,2}月\d{1,2}日)/u.test(value)) return { kind: '日记', heat: 2 };
|
|
10
|
+
return { kind: '对话', heat: 2 };
|
|
11
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -22,15 +22,18 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
if (!ctx) throw new Error('Canvas 2D 不可用');
|
|
23
23
|
const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
|
|
24
24
|
const fallback = [145,205,255];
|
|
25
|
-
let nodes = [], links = [], hubs = [], linkTwinkle = [];
|
|
25
|
+
let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
|
|
26
26
|
const byId = new Map(), adjacency = new Map(), sprites = new Map();
|
|
27
27
|
const pulses = [], firing = new Map(), selections = new Set();
|
|
28
|
+
// 默认不画连线(数量太大会卡),只画选中光点周围的线
|
|
29
|
+
let selectedId=null, selectedNeighbours=new Set();
|
|
28
30
|
if (typeof options.onSelect === 'function') selections.add(options.onSelect);
|
|
29
31
|
let seed = 987654321;
|
|
30
32
|
const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
|
|
31
33
|
let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
|
|
32
34
|
let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
|
|
33
|
-
|
|
35
|
+
const frameInterval=Number.isFinite(options.frameInterval)?Math.max(0,options.frameInterval):0;
|
|
36
|
+
let drag=null,nextFire=0,lastTime=0,lastDrawAt=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
|
|
34
37
|
|
|
35
38
|
function glowSprite(rgb) {
|
|
36
39
|
const off = document.createElement('canvas');
|
|
@@ -62,6 +65,8 @@ function glowSprite(rgb) {
|
|
|
62
65
|
nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
|
|
63
66
|
links = nextData.links.map(link => [...link]);
|
|
64
67
|
byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
|
|
68
|
+
selectedId=typeof options.selectedId==='string'?options.selectedId:null;
|
|
69
|
+
selectedNeighbours=new Set(selectedId?(adjacency.get(selectedId)||[]):[]);
|
|
65
70
|
seed = 987654321; nextFire = 0;
|
|
66
71
|
for (const node of nextData.nodes) byId.set(node.id, node);
|
|
67
72
|
for (const node of nodes) adjacency.set(node.id, []);
|
|
@@ -87,6 +92,23 @@ function glowSprite(rgb) {
|
|
|
87
92
|
jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
|
|
88
93
|
};
|
|
89
94
|
});
|
|
95
|
+
// 连线数量会随记忆增长爆炸(332 条时 383 根,479 条时 1738 根)。
|
|
96
|
+
// 每帧全画会卡,所以按"两端留存"排序,最多画 LINK_DRAW_CAP 根最亮的,
|
|
97
|
+
// 其余的不进这一帧(数据没丢,只是不画,缩放/点击仍按全量算)。
|
|
98
|
+
const LINK_DRAW_CAP = 1200;
|
|
99
|
+
if (links.length <= LINK_DRAW_CAP) {
|
|
100
|
+
visibleLinks = links.map((_, index) => index);
|
|
101
|
+
} else {
|
|
102
|
+
const byIdLocal = new Map(nodes.map(node => [node.id, node]));
|
|
103
|
+
const strength = index => {
|
|
104
|
+
const [left, right] = links[index];
|
|
105
|
+
const a = byIdLocal.get(left), b = byIdLocal.get(right);
|
|
106
|
+
return Math.min(a?.retention ?? 0, b?.retention ?? 0) + Math.min(a?.degree ?? 0, b?.degree ?? 0) * 0.002;
|
|
107
|
+
};
|
|
108
|
+
visibleLinks = links.map((_, index) => index)
|
|
109
|
+
.sort((x, y) => strength(y) - strength(x))
|
|
110
|
+
.slice(0, LINK_DRAW_CAP);
|
|
111
|
+
}
|
|
90
112
|
for (const star of nodes) {
|
|
91
113
|
star._rgb = colors[star.kind] || fallback;
|
|
92
114
|
const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
|
|
@@ -123,17 +145,19 @@ function project(pos) {
|
|
|
123
145
|
function fire(id,now) {
|
|
124
146
|
firing.set(id,1);
|
|
125
147
|
const neighbours=adjacency.get(id)||[];
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
const
|
|
148
|
+
// 选中某个光点时,把它的关联线都点亮几条脉冲(最多 24 条,避免又糊成一片);
|
|
149
|
+
// 未选中时的随机心跳只挑 2~4 条,避免整片区域同时亮。
|
|
150
|
+
const selected = id===selectedId;
|
|
151
|
+
const limit = selected ? 24 : 2+Math.floor(Math.random()*3);
|
|
152
|
+
const count=Math.min(neighbours.length,limit);
|
|
129
153
|
const picked=new Set();
|
|
130
154
|
let guard=0;
|
|
131
|
-
while (picked.size<count && neighbours.length && guard++<
|
|
155
|
+
while (picked.size<count && neighbours.length && guard++<400) {
|
|
132
156
|
picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
|
|
133
157
|
}
|
|
134
158
|
for (const other of picked) {
|
|
135
159
|
// 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
|
|
136
|
-
firing.set(other,Math.max(0.26,firing.get(other)||0));
|
|
160
|
+
firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
|
|
137
161
|
pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
|
|
138
162
|
}
|
|
139
163
|
}
|
|
@@ -149,6 +173,8 @@ function fire(id,now) {
|
|
|
149
173
|
if (score<bestScore) {bestScore=score;best=star;}
|
|
150
174
|
}
|
|
151
175
|
if (!best) return;
|
|
176
|
+
selectedId = best.id;
|
|
177
|
+
selectedNeighbours = new Set(adjacency.get(best.id) || []);
|
|
152
178
|
fire(best.id,performance.now());
|
|
153
179
|
for (const callback of selections) callback(byId.get(best.id));
|
|
154
180
|
}
|
|
@@ -187,10 +213,16 @@ function frame(now) {
|
|
|
187
213
|
if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
|
|
188
214
|
if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
|
|
189
215
|
if (now>nextFire) {
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
fire(
|
|
216
|
+
// 默认不画连线,所以也不撒"沿线的脉冲"——否则会出现飘来飘去的小点,找不到来源。
|
|
217
|
+
// 选中光点后,脉冲只在它的关联线上跑。
|
|
218
|
+
if (selectedId) {
|
|
219
|
+
fire(selectedId,now);
|
|
220
|
+
} else {
|
|
221
|
+
// 没选中时只让枢纽光点自己亮一下,不动线
|
|
222
|
+
if (hubs.length) {
|
|
223
|
+
const hub=hubs[Math.floor(Math.random()*hubs.length)];
|
|
224
|
+
firing.set(hub.id,1);
|
|
225
|
+
}
|
|
194
226
|
}
|
|
195
227
|
nextFire=now+1400+Math.random()*3200;
|
|
196
228
|
}
|
|
@@ -208,37 +240,37 @@ function frame(now) {
|
|
|
208
240
|
}
|
|
209
241
|
points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
|
|
210
242
|
const screen=new Map(points.map(point=>[point.star.id,point]));
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
ctx.
|
|
241
|
-
ctx.
|
|
243
|
+
// 连线只在"选中某个光点"时画,且只画和它相连的那些。
|
|
244
|
+
// 全量 1738 根每帧画会卡;默认干干净净,点谁看谁的关联。
|
|
245
|
+
const linkPaths=[];
|
|
246
|
+
if (selectedId) {
|
|
247
|
+
for (const [left,right] of links) {
|
|
248
|
+
if (left!==selectedId && right!==selectedId) continue;
|
|
249
|
+
const first=screen.get(left),second=screen.get(right);
|
|
250
|
+
if (!first||!second) continue;
|
|
251
|
+
const target = left===selectedId ? second : first;
|
|
252
|
+
const ret = Math.min(first.star.retention, second.star.retention);
|
|
253
|
+
const alpha = Math.min(.9, .22 + ret*.35);
|
|
254
|
+
const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
|
|
255
|
+
const len=Math.hypot(dx,dy)||1;
|
|
256
|
+
const nx=-dy/len, ny=dx/len;
|
|
257
|
+
// 朝选中点那一侧弯,形成"从中心散开"的观感
|
|
258
|
+
const bow = (target===second ? 1 : -1) * .16;
|
|
259
|
+
const mx=(first.p.x+second.p.x)/2+nx*len*bow*.5;
|
|
260
|
+
const my=(first.p.y+second.p.y)/2+ny*len*bow*.5;
|
|
261
|
+
linkPaths.push([first.p.x,first.p.y,mx,my,second.p.x,second.p.y,alpha]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (linkPaths.length) {
|
|
265
|
+
ctx.strokeStyle='rgba(110,190,255,0.30)';
|
|
266
|
+
ctx.lineWidth=1;
|
|
267
|
+
ctx.beginPath();
|
|
268
|
+
for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
|
|
269
|
+
ctx.stroke();
|
|
270
|
+
// 更亮的一层,让关联线看得清
|
|
271
|
+
ctx.strokeStyle='rgba(160,220,255,0.22)';
|
|
272
|
+
ctx.lineWidth=2;
|
|
273
|
+
ctx.stroke();
|
|
242
274
|
}
|
|
243
275
|
for (let index=pulses.length-1;index>=0;index--) {
|
|
244
276
|
const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
|
|
@@ -323,6 +355,13 @@ function frame(now) {
|
|
|
323
355
|
function tick(now) {
|
|
324
356
|
if (!running || destroyed || currentGeneration !== generation) return;
|
|
325
357
|
rafId=null;
|
|
358
|
+
// 帧率上限靠 options.frameInterval 控制(默认 0 = 不限)。
|
|
359
|
+
// 界面里按 40fps 传;测试里不传,保持逐帧确定性。
|
|
360
|
+
if (frameInterval > 0 && now - lastDrawAt < frameInterval) {
|
|
361
|
+
rafId=window.requestAnimationFrame(tick);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
lastDrawAt = now;
|
|
326
365
|
frame(now);
|
|
327
366
|
if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
|
|
328
367
|
}
|
|
@@ -537,7 +576,7 @@ function frame(now) {
|
|
|
537
576
|
setError("");
|
|
538
577
|
readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
|
|
539
578
|
if (!active) return;
|
|
540
|
-
renderer = createRenderer(canvas.current, nextData);
|
|
579
|
+
renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
|
|
541
580
|
props.rendererRef.current = renderer;
|
|
542
581
|
renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
|
|
543
582
|
renderer.start();
|
|
@@ -577,7 +616,7 @@ function frame(now) {
|
|
|
577
616
|
React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
|
|
578
617
|
React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
|
|
579
618
|
})) : null,
|
|
580
|
-
React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 ·
|
|
619
|
+
React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
|
|
581
620
|
error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
|
|
582
621
|
React.createElement("p", null, "图谱加载失败:" + error),
|
|
583
622
|
React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
|
package/lib/client.src.js
CHANGED
|
@@ -188,7 +188,7 @@ window.__ModuleLoader__.load({
|
|
|
188
188
|
setError("");
|
|
189
189
|
readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
|
|
190
190
|
if (!active) return;
|
|
191
|
-
renderer = createRenderer(canvas.current, nextData);
|
|
191
|
+
renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
|
|
192
192
|
props.rendererRef.current = renderer;
|
|
193
193
|
renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
|
|
194
194
|
renderer.start();
|
|
@@ -228,7 +228,7 @@ window.__ModuleLoader__.load({
|
|
|
228
228
|
React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
|
|
229
229
|
React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
|
|
230
230
|
})) : null,
|
|
231
|
-
React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 ·
|
|
231
|
+
React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
|
|
232
232
|
error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
|
|
233
233
|
React.createElement("p", null, "图谱加载失败:" + error),
|
|
234
234
|
React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
|
package/lib/config.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { automationConfig } from './automation-config.js';
|
|
5
|
+
import { readLastError } from './auto-log.js';
|
|
4
6
|
|
|
5
7
|
export const defaultHome = () => process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
6
8
|
|
|
@@ -11,7 +13,10 @@ export function resolveDiaryDir({ home = defaultHome(), envDir = process.env.QQ_
|
|
|
11
13
|
export function readUiConfig(home = defaultHome(), options = {}) {
|
|
12
14
|
const config = readConfig(home);
|
|
13
15
|
return { hideButton: config.hideButton === true, showGuideOnEmpty: config.showGuideOnEmpty !== false,
|
|
14
|
-
hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed
|
|
16
|
+
hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed,
|
|
17
|
+
autoMemory: { enabled: config.autoMemory.enabled, maxPerTurn: config.autoMemory.maxPerTurn },
|
|
18
|
+
autoImport: { enabled: config.autoImport.enabled, sourceCount: config.autoImport.sources.length },
|
|
19
|
+
lastError: readLastError(path.join(home, 'memory-engine', 'auto-log.json'), options.warn) };
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export function readPersona(config, { now = Date.now(), warn = message => console.warn(`[dsh-memory-engine] ${message}`) } = {}) {
|
|
@@ -34,7 +39,7 @@ export function readPersona(config, { now = Date.now(), warn = message => consol
|
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
export function readConfig(home = defaultHome()) {
|
|
42
|
+
export function readConfig(home = defaultHome(), { warn } = {}) {
|
|
38
43
|
const file = path.join(home, 'memory-engine', 'config.json');
|
|
39
44
|
try {
|
|
40
45
|
const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
|
|
@@ -46,9 +51,9 @@ export function readConfig(home = defaultHome()) {
|
|
|
46
51
|
if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
|
|
47
52
|
throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
|
|
48
53
|
}
|
|
49
|
-
return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35 };
|
|
54
|
+
return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35, ...automationConfig(value, warn) };
|
|
50
55
|
} catch (error) {
|
|
51
|
-
if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35 };
|
|
56
|
+
if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35, ...automationConfig({}, warn) };
|
|
52
57
|
throw new Error(`无法读取 ${file}:${error.message}`);
|
|
53
58
|
}
|
|
54
59
|
}
|
package/lib/dedupe.js
CHANGED
|
@@ -49,10 +49,10 @@ export function similarity(a, b, threshold = 0.7) {
|
|
|
49
49
|
return { duplicate: score >= threshold, score, rule: 'lcs' };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function findDuplicate(rows, candidate, prepared = rows.map(row => prepare(row.text))) {
|
|
52
|
+
export function findDuplicate(rows, candidate, prepared = rows.map(row => prepare(row.text)), threshold = 0.7) {
|
|
53
53
|
const text = prepare(candidate.text);
|
|
54
54
|
for (let index = 0; index < rows.length; index++) {
|
|
55
|
-
const match = similarity(prepared[index], text);
|
|
55
|
+
const match = similarity(prepared[index], text, threshold);
|
|
56
56
|
if (match.duplicate) return { index, ...match };
|
|
57
57
|
}
|
|
58
58
|
return null;
|
package/lib/file-import.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { loadMemories } from './render/index.mjs';
|
|
5
5
|
import { inferEmotion } from './decay.js';
|
|
6
6
|
|
|
7
|
-
export async function collectFile({ source, path: input }, now = Date.now()) {
|
|
7
|
+
export async function collectFile({ source, path: input, files: selectedFiles }, now = Date.now()) {
|
|
8
8
|
if (!['json', 'markdown', 'text'].includes(source)) throw new Error('source 支持 qq-bridge / json / markdown / text');
|
|
9
9
|
if (typeof input !== 'string' || !path.isAbsolute(input)) throw new Error('path 必须是本地文件或目录的绝对路径');
|
|
10
10
|
const info = await fs.stat(input);
|
|
@@ -13,15 +13,16 @@ export async function collectFile({ source, path: input }, now = Date.now()) {
|
|
|
13
13
|
try {
|
|
14
14
|
let loaderInput = input;
|
|
15
15
|
if (source === 'markdown') {
|
|
16
|
-
const files = (await fs.readdir(input, { recursive: true, withFileTypes: true }))
|
|
16
|
+
const files = selectedFiles ?? (await fs.readdir(input, { recursive: true, withFileTypes: true }))
|
|
17
17
|
.filter(entry => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
|
|
18
18
|
.map(entry => path.join(entry.parentPath, entry.name));
|
|
19
19
|
const contents = await Promise.all(files.map(async file => ({ file, text: await fs.readFile(file, 'utf8'), info: await fs.stat(file) })));
|
|
20
|
-
if (contents.some(item => item.text.startsWith('\uFEFF'))) {
|
|
20
|
+
if (selectedFiles || contents.some(item => item.text.startsWith('\uFEFF'))) {
|
|
21
21
|
temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-memory-input-'));
|
|
22
22
|
loaderInput = temporary;
|
|
23
23
|
for (const item of contents) {
|
|
24
24
|
const target = path.join(temporary, path.relative(input, item.file));
|
|
25
|
+
if (path.relative(temporary, target).startsWith('..') || path.isAbsolute(path.relative(temporary, target))) throw new Error('Markdown 文件超出导入目录');
|
|
25
26
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
26
27
|
await fs.writeFile(target, item.text.replace(/^\uFEFF/u, ''));
|
|
27
28
|
await fs.utimes(target, item.info.atime, item.info.mtime);
|
package/lib/import.js
CHANGED
|
@@ -34,15 +34,18 @@ export function resolveBridgeRoot({ defaultRoot = DEFAULT_BRIDGE_ROOT, envRoot =
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function collectBridge(options = {}) {
|
|
37
|
-
const root = resolveBridgeRoot(options);
|
|
37
|
+
const root = options.root ?? resolveBridgeRoot(options);
|
|
38
38
|
if (!root) return { missing: true, entries: [], warnings: [], counts: {} };
|
|
39
|
+
const readSource = options.io?.readFileSync ?? readFileSync;
|
|
40
|
+
const listSource = options.io?.readdirSync ?? readdirSync;
|
|
41
|
+
const statSource = options.io?.statSync ?? statSync;
|
|
39
42
|
const warnings = [];
|
|
40
43
|
const readJson = (file, fallback) => {
|
|
41
|
-
try { return JSON.parse(
|
|
44
|
+
try { return JSON.parse(readSource(file, 'utf8').replace(/^\uFEFF/u, '')); }
|
|
42
45
|
catch (error) { warnings.push(`${file}:${error.code === 'ENOENT' ? '未找到,已略过' : '无法读取或 JSON 损坏,已略过'}`); return fallback; }
|
|
43
46
|
};
|
|
44
47
|
const files = directory => {
|
|
45
|
-
try { return
|
|
48
|
+
try { return listSource(directory, { withFileTypes: true }).filter(entry => entry.isFile()).map(entry => entry.name).sort(); }
|
|
46
49
|
catch { warnings.push(`${directory}:无法读取目录,已略过`); return []; }
|
|
47
50
|
};
|
|
48
51
|
const state = path.join(root, 'state');
|
|
@@ -117,7 +120,7 @@ export function collectBridge(options = {}) {
|
|
|
117
120
|
for (const file of (diaryDir ? files(diaryDir) : []).filter(name => !name.startsWith('_') && name.toLowerCase().endsWith('.md'))) {
|
|
118
121
|
const full = path.join(diaryDir, file);
|
|
119
122
|
try {
|
|
120
|
-
add('日记', { text:
|
|
123
|
+
add('日记', { text: readSource(full, 'utf8'), kind: '日记', ts: statSource(full).mtimeMs, title: path.basename(file, path.extname(file)) },
|
|
121
124
|
full, [`diary-${file}`]);
|
|
122
125
|
} catch { warnings.push(`${file}:日记无法读取,已略过`); }
|
|
123
126
|
}
|
|
@@ -132,7 +135,7 @@ export function collectBridge(options = {}) {
|
|
|
132
135
|
const todayMessages = [];
|
|
133
136
|
for (const file of files(path.join(state, 'history')).filter(name => name.endsWith('.jsonl'))) {
|
|
134
137
|
let lines;
|
|
135
|
-
try { lines =
|
|
138
|
+
try { lines = readSource(path.join(state, 'history', file), 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u); }
|
|
136
139
|
catch { warnings.push(`${file}:历史文件无法读取,已略过`); continue; }
|
|
137
140
|
let broken = 0;
|
|
138
141
|
for (const line of lines) {
|