local-knowledge-graph 1.10.3 → 1.11.0
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/HELP.md +24 -1
- package/README.md +10 -4
- package/lib/agent.js +22 -6
- package/lib/db.js +133 -11
- package/lib/ocbin.js +194 -0
- package/lib/ocinstall.js +229 -0
- package/lib/triples_io.js +229 -0
- package/lib/validator.js +35 -8
- package/lib/viewer_template.html +44 -6
- package/package.json +1 -1
- package/public/app.js +457 -23
- package/public/index.html +62 -0
- package/public/style.css +13 -0
- package/server.js +82 -6
package/lib/ocinstall.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// OpenCode 一键安装与模型配置:
|
|
4
|
+
// - 安装:npm 全局安装 opencode-ai。本应用本身即 npm 包,npm 必然可用,
|
|
5
|
+
// 且全程无交互,Windows/macOS/Linux 通用(curl 脚本在 Windows 不可用)。
|
|
6
|
+
// - 模型配置(写入 opencode 全局配置 ~/.config/opencode/opencode.json):
|
|
7
|
+
// 1) 已有认证(auth.json)或已设默认模型 → 尊重现有配置,不改动
|
|
8
|
+
// 2) 本机 Ollama (127.0.0.1:11434) → 自动写入 provider 与默认模型
|
|
9
|
+
// 3) 本机 LM Studio (127.0.0.1:1234) → 同上
|
|
10
|
+
// 4) 兜底:OpenCode Zen 免费模型 opencode/grok-code(官方源码确认免费模型无需登录)
|
|
11
|
+
// - 完成后通过 onDone 钩子通知调用方重新检测,无需重启应用即可使用。
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const NPM_PKG = 'opencode-ai';
|
|
19
|
+
const FREE_MODEL = 'opencode/grok-code';
|
|
20
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
|
+
const LOG_KEEP = 300;
|
|
22
|
+
|
|
23
|
+
// phase: idle | installing | configuring | done | error
|
|
24
|
+
const state = {
|
|
25
|
+
phase: 'idle',
|
|
26
|
+
step: '',
|
|
27
|
+
log: [],
|
|
28
|
+
error: null,
|
|
29
|
+
model: null, // { source: existing|ollama|lmstudio|free|error, model, detail }
|
|
30
|
+
started_at: null,
|
|
31
|
+
ended_at: null,
|
|
32
|
+
};
|
|
33
|
+
let running = false;
|
|
34
|
+
let onDone = null; // 安装完成后的回调(server 用它重新 checkAgent)
|
|
35
|
+
|
|
36
|
+
function pushLog(line) {
|
|
37
|
+
const t = new Date().toISOString().slice(11, 19);
|
|
38
|
+
state.log.push(`[${t}] ${line}`);
|
|
39
|
+
if (state.log.length > LOG_KEEP) state.log.splice(0, state.log.length - LOG_KEEP);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// npm 与 node 同目录(官方安装/nvm 均如此),GUI 启动 PATH 缺失时也能找到
|
|
43
|
+
function npmCommand() {
|
|
44
|
+
const dir = path.dirname(process.execPath);
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
const local = path.join(dir, 'npm.cmd');
|
|
47
|
+
return { file: fs.existsSync(local) ? local : 'npm.cmd', shell: true };
|
|
48
|
+
}
|
|
49
|
+
const local = path.join(dir, 'npm');
|
|
50
|
+
return { file: fs.existsSync(local) ? local : 'npm', shell: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runNpmInstall() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const npm = npmCommand();
|
|
56
|
+
let child;
|
|
57
|
+
try {
|
|
58
|
+
// --no-audit/--no-fund:跳过审计与赞助提示,大包安装明显提速
|
|
59
|
+
child = spawn(npm.file, ['install', '-g', NPM_PKG, '--no-audit', '--no-fund'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: npm.shell });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return resolve({ ok: false, error: `无法启动 npm: ${e.message}` });
|
|
62
|
+
}
|
|
63
|
+
// npm 非 TTY 运行时几乎没有中间输出,用心跳日志证明进程仍在下载
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
const heartbeat = setInterval(() => pushLog(`下载安装中… 已耗时 ${Math.round((Date.now() - t0) / 1000)}s(首次需下载较大二进制,请耐心等待)`), 15000);
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
try { child.kill('SIGKILL'); } catch (_) { /* 已退出 */ }
|
|
68
|
+
resolve({ ok: false, error: `npm 安装超时(${INSTALL_TIMEOUT_MS / 60000}分钟),已终止` });
|
|
69
|
+
}, INSTALL_TIMEOUT_MS);
|
|
70
|
+
const finish = (r) => { clearInterval(heartbeat); clearTimeout(timer); resolve(r); };
|
|
71
|
+
child.stdout.on('data', (d) => String(d).split('\n').forEach((l) => { if (l.trim()) pushLog(l.trim()); }));
|
|
72
|
+
child.stderr.on('data', (d) => String(d).split('\n').forEach((l) => { if (l.trim()) pushLog(l.trim()); }));
|
|
73
|
+
child.on('error', (e) => finish({ ok: false, error: `npm 启动失败: ${e.message}` }));
|
|
74
|
+
child.on('close', (code) => {
|
|
75
|
+
finish(code === 0 ? { ok: true } : { ok: false, error: `npm 退出码 ${code}(详见日志,可检查网络后重试)` });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- opencode 配置文件 ----
|
|
81
|
+
|
|
82
|
+
function xdgConfigHome() { return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); }
|
|
83
|
+
function xdgDataHome() { return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); }
|
|
84
|
+
function globalConfigPath() { return path.join(xdgConfigHome(), 'opencode', 'opencode.json'); }
|
|
85
|
+
function authFilePath() { return path.join(xdgDataHome(), 'opencode', 'auth.json'); }
|
|
86
|
+
|
|
87
|
+
function readJsonSafe(p) {
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function existingSetup() {
|
|
92
|
+
const auth = readJsonSafe(authFilePath());
|
|
93
|
+
if (auth && typeof auth === 'object' && Object.keys(auth).length) return { configured: true, where: 'auth' };
|
|
94
|
+
const cfg = readJsonSafe(globalConfigPath());
|
|
95
|
+
if (cfg && typeof cfg === 'object' && cfg.model) return { configured: true, where: 'config' };
|
|
96
|
+
return { configured: false, where: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 合并写入全局配置;原文件不可解析(如 jsonc 带注释)时先备份再重写
|
|
100
|
+
function writeGlobalConfig(mutate) {
|
|
101
|
+
const p = globalConfigPath();
|
|
102
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
103
|
+
let cfg = readJsonSafe(p);
|
|
104
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
105
|
+
if (fs.existsSync(p)) {
|
|
106
|
+
try { fs.copyFileSync(p, `${p}.bak-${Date.now()}`); } catch (_) { /* 备份失败也继续 */ }
|
|
107
|
+
}
|
|
108
|
+
cfg = {};
|
|
109
|
+
}
|
|
110
|
+
mutate(cfg);
|
|
111
|
+
if (!cfg.$schema) cfg.$schema = 'https://opencode.ai/config.json';
|
|
112
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fetchJson(url, timeoutMs = 2000) {
|
|
117
|
+
return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
118
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
119
|
+
.catch(() => null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const EMBED_RE = /embed|bge|clip|rerank|minilm|nomic|e5[-_]|jina|gte/i;
|
|
123
|
+
|
|
124
|
+
async function detectLocalModels() {
|
|
125
|
+
const ollama = await fetchJson('http://127.0.0.1:11434/api/tags');
|
|
126
|
+
const oModels = ((ollama && Array.isArray(ollama.models)) ? ollama.models : [])
|
|
127
|
+
.map((m) => m && m.name).filter((n) => n && !EMBED_RE.test(n));
|
|
128
|
+
if (oModels.length) {
|
|
129
|
+
return { provider: 'ollama', baseURL: 'http://localhost:11434/v1', label: 'Ollama', models: oModels, pick: oModels[0] };
|
|
130
|
+
}
|
|
131
|
+
const lms = await fetchJson('http://127.0.0.1:1234/v1/models');
|
|
132
|
+
const lModels = ((lms && Array.isArray(lms.data)) ? lms.data : [])
|
|
133
|
+
.map((m) => m && m.id).filter((n) => n && !EMBED_RE.test(n));
|
|
134
|
+
if (lModels.length) {
|
|
135
|
+
return { provider: 'lmstudio', baseURL: 'http://127.0.0.1:1234/v1', label: 'LM Studio', models: lModels, pick: lModels[0] };
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyLocalProvider(local) {
|
|
141
|
+
const models = {};
|
|
142
|
+
for (const id of local.models) models[id] = { name: id };
|
|
143
|
+
writeGlobalConfig((cfg) => {
|
|
144
|
+
cfg.provider = cfg.provider || {};
|
|
145
|
+
cfg.provider[local.provider] = {
|
|
146
|
+
npm: '@ai-sdk/openai-compatible',
|
|
147
|
+
name: `${local.label} (local)`,
|
|
148
|
+
options: { baseURL: local.baseURL },
|
|
149
|
+
models,
|
|
150
|
+
};
|
|
151
|
+
cfg.model = `${local.provider}/${local.pick}`;
|
|
152
|
+
});
|
|
153
|
+
return { source: local.provider, model: `${local.provider}/${local.pick}`, detail: `检测到本机 ${local.label},已接入 ${local.models.length} 个模型,默认使用 ${local.pick}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyFreeModel() {
|
|
157
|
+
writeGlobalConfig((cfg) => { cfg.model = FREE_MODEL; });
|
|
158
|
+
return { source: 'free', model: FREE_MODEL, detail: '未检测到本机模型,已设置 OpenCode Zen 免费模型(无需登录,可在 opencode 配置中更换)' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function configureModel() {
|
|
162
|
+
const existing = existingSetup();
|
|
163
|
+
if (existing.configured) {
|
|
164
|
+
return { source: 'existing', model: null, detail: existing.where === 'auth' ? '检测到已有 opencode 认证,保留现有模型配置' : '检测到已设置默认模型,保留现有配置' };
|
|
165
|
+
}
|
|
166
|
+
const local = await detectLocalModels();
|
|
167
|
+
return local ? applyLocalProvider(local) : applyFreeModel();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- 主流程 ----
|
|
171
|
+
|
|
172
|
+
async function installFlow() {
|
|
173
|
+
running = true;
|
|
174
|
+
Object.assign(state, { phase: 'installing', step: `正在通过 npm 安装 ${NPM_PKG}…`, error: null, model: null, started_at: Date.now(), ended_at: null });
|
|
175
|
+
pushLog(`开始安装 ${NPM_PKG}`);
|
|
176
|
+
|
|
177
|
+
const inst = await runNpmInstall();
|
|
178
|
+
if (!inst.ok) {
|
|
179
|
+
Object.assign(state, { phase: 'error', step: '', error: inst.error, ended_at: Date.now() });
|
|
180
|
+
pushLog(`安装失败: ${inst.error}`);
|
|
181
|
+
running = false;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
pushLog('npm 安装完成,正在识别 opencode…');
|
|
185
|
+
state.phase = 'configuring';
|
|
186
|
+
state.step = '正在检测安装结果与配置模型…';
|
|
187
|
+
|
|
188
|
+
const ocbin = require('./ocbin');
|
|
189
|
+
const bin = ocbin.findOpenCodeBin({ refresh: true });
|
|
190
|
+
const version = bin && ocbin.probeVersion(bin);
|
|
191
|
+
if (!version) {
|
|
192
|
+
const msg = `安装完成但未能识别 opencode${bin ? `(${bin.path} 探测失败)` : ''},可用环境变量 KG_OPENCODE_PATH 指定可执行文件路径后重试`;
|
|
193
|
+
Object.assign(state, { phase: 'error', step: '', error: msg, ended_at: Date.now() });
|
|
194
|
+
pushLog(msg);
|
|
195
|
+
running = false;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
pushLog(`已识别 opencode: ${bin.path}(${version})`);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
state.model = await configureModel();
|
|
202
|
+
pushLog(`模型配置: ${state.model.detail}${state.model.model ? `,默认模型 ${state.model.model}` : ''}`);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
state.model = { source: 'error', model: null, detail: `模型配置失败: ${e.message}(opencode 本体已可用,可手动在其配置中设置模型)` };
|
|
205
|
+
pushLog(state.model.detail);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
Object.assign(state, { phase: 'done', step: '', ended_at: Date.now() });
|
|
209
|
+
pushLog('全部完成,OpenCode 已就绪');
|
|
210
|
+
running = false;
|
|
211
|
+
if (typeof onDone === 'function') { try { onDone(); } catch (_) { /* 回调异常不影响状态 */ } }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function startInstall(force = false) {
|
|
215
|
+
if (running) return { started: false, reason: '安装正在进行中' };
|
|
216
|
+
if (state.phase === 'done' && !force) return { started: false, reason: '已安装完成,无需重复安装(强制重装请带 force)' };
|
|
217
|
+
if (force) Object.assign(state, { phase: 'idle', log: [], error: null, model: null });
|
|
218
|
+
installFlow().catch((e) => {
|
|
219
|
+
Object.assign(state, { phase: 'error', step: '', error: e.message, ended_at: Date.now() });
|
|
220
|
+
running = false;
|
|
221
|
+
});
|
|
222
|
+
return { started: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function status() {
|
|
226
|
+
return { ...state, running, log: state.log.slice(-40) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { startInstall, status, FREE_MODEL, set onDone(fn) { onDone = fn; }, _internal: { configureModel, existingSetup, detectLocalModels, globalConfigPath, authFilePath } };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 三元组批量导入/导出(CSV / JSON):
|
|
4
|
+
// - JSON 格式:{ entities:[{name,category,attributes}], relations:[{source,target,name,category,...}] }
|
|
5
|
+
// 导出时附带 id 与 confidence/source_ref;导入按 name 引用(纯数字优先按 id 解析)。
|
|
6
|
+
// - CSV 格式:中文表头 kind,name,category,source,target,relation,relation_category,confidence,source_ref,attributes
|
|
7
|
+
// 实体行:kind=实体;关系行:kind=关系。attributes 列为 JSON 字符串。
|
|
8
|
+
// - 导入语义:同名实体自动复用(不存在才创建);关系去重校验与手工添加完全一致;
|
|
9
|
+
// 单行失败不中断整批,错误逐条收集返回。
|
|
10
|
+
|
|
11
|
+
const V = require('./validator');
|
|
12
|
+
|
|
13
|
+
const CSV_HEADERS = ['kind', 'name', 'category', 'source', 'target', 'relation', 'relation_category', 'confidence', 'source_ref', 'attributes'];
|
|
14
|
+
const CSV_HEADERS_CN = '种类,名称,大类,起点,终点,关系,关系大类,置信度,来源引用,属性';
|
|
15
|
+
|
|
16
|
+
// ---- CSV 序列化(RFC4180)----
|
|
17
|
+
function csvCell(v) {
|
|
18
|
+
const s = v === null || v === undefined ? '' : String(v);
|
|
19
|
+
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseAttributesCell(raw, errors, where) {
|
|
23
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return {};
|
|
24
|
+
try {
|
|
25
|
+
const v = JSON.parse(raw);
|
|
26
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return v;
|
|
27
|
+
errors.push(`${where}: 属性列必须是JSON对象`);
|
|
28
|
+
} catch (_) {
|
|
29
|
+
errors.push(`${where}: 属性列不是合法JSON`);
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 属性规范化:db 行的 attributes 可能是 JSON 字符串(getGraph 原样)或对象,统一为对象再序列化
|
|
35
|
+
function normAttrs(a) {
|
|
36
|
+
if (a && typeof a === 'object') return a;
|
|
37
|
+
return safeParse(a);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function entitiesToCsvRows(entities) {
|
|
41
|
+
return entities.map((e) => ([
|
|
42
|
+
'实体', e.name, e.category, '', '', '', '', '', '',
|
|
43
|
+
JSON.stringify(normAttrs(e.attributes)),
|
|
44
|
+
]));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function relationsToCsvRows(relations, nameOf) {
|
|
48
|
+
return relations.map((r) => ([
|
|
49
|
+
'关系', '', r.category || '', nameOf(r.source_id), nameOf(r.target_id), r.name, r.category, r.confidence || '确证', r.source_ref || '',
|
|
50
|
+
JSON.stringify(normAttrs(r.attributes)),
|
|
51
|
+
]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function exportCsv(graph) {
|
|
55
|
+
const nameOf = (id) => {
|
|
56
|
+
const e = graph.entities.find((x) => x.id === id);
|
|
57
|
+
return e ? e.name : '#' + id;
|
|
58
|
+
};
|
|
59
|
+
const rows = [CSV_HEADERS_CN];
|
|
60
|
+
for (const r of entitiesToCsvRows(graph.entities)) rows.push(r.map(csvCell));
|
|
61
|
+
for (const r of relationsToCsvRows(graph.relations, nameOf)) rows.push(r.map(csvCell));
|
|
62
|
+
return rows.join('\r\n') + '\r\n';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function exportJson(graph) {
|
|
66
|
+
return JSON.stringify({
|
|
67
|
+
format: 'knowledge-graph-triples',
|
|
68
|
+
version: 1,
|
|
69
|
+
exported_at: new Date().toISOString(),
|
|
70
|
+
entities: graph.entities.map((e) => ({ id: e.id, name: e.name, category: e.category, attributes: safeParse(e.attributes) })),
|
|
71
|
+
relations: graph.relations.map((r) => ({
|
|
72
|
+
source_id: r.source_id,
|
|
73
|
+
target_id: r.target_id,
|
|
74
|
+
source: (graph.entities.find((x) => x.id === r.source_id) || {}).name || '#' + r.source_id,
|
|
75
|
+
target: (graph.entities.find((x) => x.id === r.target_id) || {}).name || '#' + r.target_id,
|
|
76
|
+
name: r.name,
|
|
77
|
+
category: r.category,
|
|
78
|
+
confidence: r.confidence || '确证',
|
|
79
|
+
source_ref: r.source_ref || '',
|
|
80
|
+
attributes: safeParse(r.attributes),
|
|
81
|
+
})),
|
|
82
|
+
}, null, 2) + '\n';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeParse(s) {
|
|
86
|
+
try { return JSON.parse(s || '{}') || {}; } catch (_) { return {}; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- CSV 解析(支持引号内逗号/换行/转义引号)----
|
|
90
|
+
function parseCsvText(text) {
|
|
91
|
+
const rows = [];
|
|
92
|
+
let row = [];
|
|
93
|
+
let cell = '';
|
|
94
|
+
let inQuotes = false;
|
|
95
|
+
const src = String(text).replace(/^\uFEFF/, '');
|
|
96
|
+
for (let i = 0; i < src.length; i++) {
|
|
97
|
+
const c = src[i];
|
|
98
|
+
if (inQuotes) {
|
|
99
|
+
if (c === '"') {
|
|
100
|
+
if (src[i + 1] === '"') { cell += '"'; i++; }
|
|
101
|
+
else inQuotes = false;
|
|
102
|
+
} else cell += c;
|
|
103
|
+
} else if (c === '"') {
|
|
104
|
+
inQuotes = true;
|
|
105
|
+
} else if (c === ',') {
|
|
106
|
+
row.push(cell); cell = '';
|
|
107
|
+
} else if (c === '\r') {
|
|
108
|
+
/* 跳过 */
|
|
109
|
+
} else if (c === '\n') {
|
|
110
|
+
row.push(cell); rows.push(row); row = []; cell = '';
|
|
111
|
+
} else cell += c;
|
|
112
|
+
}
|
|
113
|
+
if (cell !== '' || row.length) { row.push(cell); rows.push(row); }
|
|
114
|
+
return rows.filter((r) => r.some((c) => String(c).trim() !== ''));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 统一解析入口:返回 {entities:[{name,category,attributes}], relations:[{source,target,name,category,confidence,source_ref,attributes}], warnings[]}
|
|
118
|
+
function parseImport(content, format) {
|
|
119
|
+
const errors = [];
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const entities = [];
|
|
122
|
+
const relations = [];
|
|
123
|
+
const fmt = String(format || '').toLowerCase();
|
|
124
|
+
|
|
125
|
+
if (fmt === 'json') {
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(content);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { ok: false, error: `JSON 解析失败: ${e.message}` };
|
|
131
|
+
}
|
|
132
|
+
const entList = Array.isArray(data) ? data : data.entities;
|
|
133
|
+
const relList = Array.isArray(data) ? [] : (data.relations || []);
|
|
134
|
+
if (!Array.isArray(entList)) return { ok: false, error: 'JSON 需包含 entities 数组(或顶层数组)' };
|
|
135
|
+
for (const e of entList) {
|
|
136
|
+
if (!e || typeof e !== 'object') continue;
|
|
137
|
+
entities.push({
|
|
138
|
+
name: e.name, category: e.category || '抽象实体',
|
|
139
|
+
attributes: e.attributes && typeof e.attributes === 'object' ? e.attributes : {},
|
|
140
|
+
fromId: Number.isInteger(e.id) ? e.id : null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const r of relList) {
|
|
144
|
+
if (!r || typeof r !== 'object') continue;
|
|
145
|
+
relations.push({
|
|
146
|
+
source: r.source !== undefined ? r.source : r.source_id,
|
|
147
|
+
target: r.target !== undefined ? r.target : r.target_id,
|
|
148
|
+
name: r.name, category: r.category || '互动',
|
|
149
|
+
confidence: r.confidence, source_ref: r.source_ref || '',
|
|
150
|
+
attributes: r.attributes && typeof r.attributes === 'object' ? r.attributes : {},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} else if (fmt === 'csv') {
|
|
154
|
+
const rows = parseCsvText(content);
|
|
155
|
+
if (!rows.length) return { ok: false, error: 'CSV 为空' };
|
|
156
|
+
let idx = kindIdx(headers(rows[0]));
|
|
157
|
+
// 首行是合法表头则从第2行起读数据;否则整份内容按默认列序解析(首行也是数据)
|
|
158
|
+
const start = idx ? 1 : 0;
|
|
159
|
+
if (!idx) idx = defaultIdx();
|
|
160
|
+
for (let i = start; i < rows.length; i++) {
|
|
161
|
+
const c = rows[i];
|
|
162
|
+
const get = (n) => (idx[n] < c.length ? String(c[idx[n]]).trim() : '');
|
|
163
|
+
const where = `第${i + 1}行`;
|
|
164
|
+
const kind = get('kind');
|
|
165
|
+
if (kind === '实体' || kind === 'entity') {
|
|
166
|
+
entities.push({ name: get('name'), category: get('category') || '抽象实体', attributes: parseAttributesCell(get('attributes'), errors, where), fromId: null });
|
|
167
|
+
} else if (kind === '关系' || kind === 'relation') {
|
|
168
|
+
relations.push({
|
|
169
|
+
source: get('source') || get('name'),
|
|
170
|
+
target: get('target'),
|
|
171
|
+
name: get('relation'),
|
|
172
|
+
category: get('relation_category') || get('category') || '互动',
|
|
173
|
+
confidence: get('confidence') || undefined,
|
|
174
|
+
source_ref: get('source_ref'),
|
|
175
|
+
attributes: parseAttributesCell(get('attributes'), errors, where),
|
|
176
|
+
});
|
|
177
|
+
} else if (kind) {
|
|
178
|
+
warnings.push(`${where}: 未知种类"${kind}",已跳过`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
return { ok: false, error: '格式必须为 csv 或 json' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 基础合法性过滤(深度校验在入库时进行,逐行收集错误)
|
|
186
|
+
const validEntities = [];
|
|
187
|
+
for (const e of entities) {
|
|
188
|
+
if (!e.name) { errors.push(`实体缺少名称: ${JSON.stringify(e.name)}`); continue; }
|
|
189
|
+
if (!V.ENTITY_CATEGORIES.includes(e.category)) {
|
|
190
|
+
warnings.push(`实体"${e.name}"大类"${e.category}"非法,已按"抽象实体"导入`);
|
|
191
|
+
e.category = '抽象实体';
|
|
192
|
+
}
|
|
193
|
+
validEntities.push(e);
|
|
194
|
+
}
|
|
195
|
+
const validRelations = [];
|
|
196
|
+
for (const r of relations) {
|
|
197
|
+
if (!r.source || !r.target || !r.name) { errors.push(`关系缺少起点/终点/名称: ${JSON.stringify(r)}`); continue; }
|
|
198
|
+
if (!V.RELATION_CATEGORIES.includes(r.category)) {
|
|
199
|
+
warnings.push(`关系"${r.name}"大类"${r.category}"非法,已按"互动"导入`);
|
|
200
|
+
r.category = '互动';
|
|
201
|
+
}
|
|
202
|
+
validRelations.push(r);
|
|
203
|
+
}
|
|
204
|
+
return { ok: true, entities: validEntities, relations: validRelations, errors, warnings };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function headers(row) { return row.map((c) => String(c).trim()); }
|
|
208
|
+
function kindIdx(cols) {
|
|
209
|
+
const map = {};
|
|
210
|
+
cols.forEach((c, i) => { map[c] = i; });
|
|
211
|
+
if (map['种类'] === undefined && map['kind'] === undefined) return null;
|
|
212
|
+
return {
|
|
213
|
+
kind: map['种类'] !== undefined ? map['种类'] : map['kind'],
|
|
214
|
+
name: map['名称'] !== undefined ? map['名称'] : (map['name'] !== undefined ? map['name'] : 1),
|
|
215
|
+
category: map['大类'] !== undefined ? map['大类'] : (map['category'] !== undefined ? map['category'] : 2),
|
|
216
|
+
source: map['起点'] !== undefined ? map['起点'] : (map['source'] !== undefined ? map['source'] : 3),
|
|
217
|
+
target: map['终点'] !== undefined ? map['终点'] : (map['target'] !== undefined ? map['target'] : 4),
|
|
218
|
+
relation: map['关系'] !== undefined ? map['关系'] : (map['relation'] !== undefined ? map['relation'] : 5),
|
|
219
|
+
relation_category: map['关系大类'] !== undefined ? map['关系大类'] : 6,
|
|
220
|
+
confidence: map['置信度'] !== undefined ? map['置信度'] : (map['confidence'] !== undefined ? map['confidence'] : 7),
|
|
221
|
+
source_ref: map['来源引用'] !== undefined ? map['来源引用'] : (map['source_ref'] !== undefined ? map['source_ref'] : 8),
|
|
222
|
+
attributes: map['属性'] !== undefined ? map['属性'] : (map['attributes'] !== undefined ? map['attributes'] : 9),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function defaultIdx() {
|
|
226
|
+
return { kind: 0, name: 1, category: 2, source: 3, target: 4, relation: 5, relation_category: 6, confidence: 7, source_ref: 8, attributes: 9 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { exportCsv, exportJson, parseImport, CSV_HEADERS_CN };
|
package/lib/validator.js
CHANGED
|
@@ -24,6 +24,23 @@ function isPrimitive(v) {
|
|
|
24
24
|
return v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// 扁平属性校验(实体与关系共用):键为非空字符串,值为原始类型,禁止嵌套
|
|
28
|
+
function checkFlatAttributes(attributes, label, errors) {
|
|
29
|
+
if (attributes === undefined) return {};
|
|
30
|
+
if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
31
|
+
errors.push(`${label}(attributes)必须为JSON对象`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
for (const [k, v] of Object.entries(attributes)) {
|
|
35
|
+
if (typeof k !== 'string' || k.trim().length === 0) errors.push(`属性键"${k}"必须为非空字符串`);
|
|
36
|
+
if (!isPrimitive(v)) {
|
|
37
|
+
errors.push(`属性"${k}"的值必须为字符串/数值/布尔/空值(扁平三元组结构),禁止嵌套对象或数组`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys(attributes).length > 100) errors.push(`${label}键数量不得超过100`);
|
|
41
|
+
return attributes;
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
// 实体校验:三元组结构 (subject=实体, predicate=属性键, object=原子值)
|
|
28
45
|
// 属性必须是扁平JSON对象,键为非空字符串,值为原始类型;禁止嵌套与冗余字段
|
|
29
46
|
function validateEntityInput(input) {
|
|
@@ -44,13 +61,7 @@ function validateEntityInput(input) {
|
|
|
44
61
|
errors.push('属性(attributes)必须为JSON对象');
|
|
45
62
|
attributes = null;
|
|
46
63
|
} else {
|
|
47
|
-
|
|
48
|
-
if (typeof k !== 'string' || k.trim().length === 0) errors.push(`属性键"${k}"必须为非空字符串`);
|
|
49
|
-
if (!isPrimitive(v)) {
|
|
50
|
-
errors.push(`属性"${k}"的值必须为字符串/数值/布尔/空值(扁平三元组结构),禁止嵌套对象或数组`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (Object.keys(attributes).length > 100) errors.push('属性键数量不得超过100');
|
|
64
|
+
checkFlatAttributes(attributes, '属性', errors);
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
if (errors.length) return { ok: false, errors, value: null };
|
|
@@ -116,8 +127,18 @@ function validateRelationInput(input, db) {
|
|
|
116
127
|
if (typeof source_ref !== 'string') errors.push('来源引用(source_ref)必须为字符串');
|
|
117
128
|
else if (source_ref.length > 500) errors.push('来源引用长度不得超过500字符');
|
|
118
129
|
|
|
130
|
+
const attributes = checkFlatAttributes(input.attributes === undefined ? {} : input.attributes, '关系属性', errors);
|
|
131
|
+
|
|
119
132
|
if (errors.length) return { ok: false, errors, value: null };
|
|
120
|
-
return { ok: true, errors: [], value: { source_id: sid, target_id: tid, name: name.trim(), category, confidence, source_ref } };
|
|
133
|
+
return { ok: true, errors: [], value: { source_id: sid, target_id: tid, name: name.trim(), category, confidence, source_ref, attributes } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 重复三元组检测:同起点+终点+关系名视为重复;excludeId 供更新时排除自身
|
|
137
|
+
function findDuplicateRelation(value, db, excludeId) {
|
|
138
|
+
const row = db.prepare(
|
|
139
|
+
'SELECT id, source_id, target_id, name FROM relations WHERE source_id = ? AND target_id = ? AND name = ? AND id != ? LIMIT 1'
|
|
140
|
+
).get(value.source_id, value.target_id, value.name, excludeId || 0);
|
|
141
|
+
return row || null;
|
|
121
142
|
}
|
|
122
143
|
|
|
123
144
|
function validateRelationPatch(patch, existing, db) {
|
|
@@ -129,6 +150,7 @@ function validateRelationPatch(patch, existing, db) {
|
|
|
129
150
|
category: existing.category,
|
|
130
151
|
confidence: existing.confidence || '确证',
|
|
131
152
|
source_ref: existing.source_ref || '',
|
|
153
|
+
attributes: JSON.parse(existing.attributes || '{}'),
|
|
132
154
|
};
|
|
133
155
|
if (patch.source_id !== undefined) merged.source_id = patch.source_id;
|
|
134
156
|
if (patch.target_id !== undefined) merged.target_id = patch.target_id;
|
|
@@ -151,6 +173,10 @@ function validateRelationPatch(patch, existing, db) {
|
|
|
151
173
|
else if (patch.source_ref.length > 500) errors.push('来源引用长度不得超过500字符');
|
|
152
174
|
else merged.source_ref = patch.source_ref;
|
|
153
175
|
}
|
|
176
|
+
if (patch.attributes !== undefined) {
|
|
177
|
+
checkFlatAttributes(patch.attributes, '关系属性', errors);
|
|
178
|
+
if (!errors.length) merged.attributes = patch.attributes;
|
|
179
|
+
}
|
|
154
180
|
const check = validateRelationInput(merged, db);
|
|
155
181
|
if (errors.length || !check.ok) return { ok: false, errors: [...errors, ...check.errors], value: null };
|
|
156
182
|
return check;
|
|
@@ -167,4 +193,5 @@ module.exports = {
|
|
|
167
193
|
validateEntityPatch,
|
|
168
194
|
validateRelationInput,
|
|
169
195
|
validateRelationPatch,
|
|
196
|
+
findDuplicateRelation,
|
|
170
197
|
};
|
package/lib/viewer_template.html
CHANGED
|
@@ -166,18 +166,38 @@ function buildNodeMesh(category) {
|
|
|
166
166
|
const a = simNodes.find(n => n.id === r.source_id), b = simNodes.find(n => n.id === r.target_id);
|
|
167
167
|
if (!a || !b) return;
|
|
168
168
|
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
169
|
+
const conf = r.confidence || '确证';
|
|
170
|
+
const op0 = st.opacity===undefined?0.9:st.opacity;
|
|
171
|
+
const op = conf === '存疑' ? Math.min(op0, 0.35) : op0;
|
|
172
|
+
const dashed = st.dashed || conf === '推测';
|
|
169
173
|
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
170
|
-
const mat =
|
|
171
|
-
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize||6, gapSize: st.gapSize||4, transparent: true, opacity:
|
|
172
|
-
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity:
|
|
174
|
+
const mat = dashed
|
|
175
|
+
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize||6, gapSize: st.gapSize||4, transparent: true, opacity: op })
|
|
176
|
+
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
173
177
|
const line = new THREE.Line(geo, mat);
|
|
174
178
|
line.userData.relationId = r.id;
|
|
175
179
|
linkGroup.add(line);
|
|
176
180
|
const lbl = makeLabelSprite(r.name, st.css, 24);
|
|
177
181
|
lbl.position.copy(a.pos).add(b.pos).multiplyScalar(0.5);
|
|
178
182
|
labelGroup.add(lbl);
|
|
179
|
-
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed
|
|
183
|
+
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed });
|
|
180
184
|
});
|
|
185
|
+
// 平行边标签错开:同节点对多条关系时标签沿垂直方向交替偏移,避免文字重叠
|
|
186
|
+
(() => {
|
|
187
|
+
const pairCount = new Map(), pairSeen = new Map();
|
|
188
|
+
for (const r of GRAPH.relations) {
|
|
189
|
+
const k = r.source_id < r.target_id ? r.source_id+'|'+r.target_id : r.target_id+'|'+r.source_id;
|
|
190
|
+
pairCount.set(k, (pairCount.get(k)||0)+1);
|
|
191
|
+
}
|
|
192
|
+
for (const l of simLinks) {
|
|
193
|
+
const r = GRAPH.relations.find(x => x.id === l.id);
|
|
194
|
+
if (!r) continue;
|
|
195
|
+
const k = r.source_id < r.target_id ? r.source_id+'|'+r.target_id : r.target_id+'|'+r.source_id;
|
|
196
|
+
const n = pairCount.get(k)||1, i = pairSeen.get(k)||0;
|
|
197
|
+
pairSeen.set(k, i+1);
|
|
198
|
+
l.arcIdx = n > 1 ? i - (n-1)/2 : 0;
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
181
201
|
})();
|
|
182
202
|
|
|
183
203
|
document.getElementById('legend').innerHTML =
|
|
@@ -209,7 +229,19 @@ function simStep() {
|
|
|
209
229
|
pa.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
|
|
210
230
|
pa.needsUpdate = true;
|
|
211
231
|
if (l.dashed) l.line.computeLineDistances();
|
|
212
|
-
|
|
232
|
+
// 标签置于边中点;平行边按序号沿垂直方向错开(n = dir × up)
|
|
233
|
+
const dx = l.b.pos.x-l.a.pos.x, dy = l.b.pos.y-l.a.pos.y, dz = l.b.pos.z-l.a.pos.z;
|
|
234
|
+
const len = Math.max(Math.sqrt(dx*dx+dy*dy+dz*dz), 0.01);
|
|
235
|
+
let nx, ny, nz;
|
|
236
|
+
if (Math.abs(dy) / len > 0.92) { nx = 0; ny = dz; nz = -dy; }
|
|
237
|
+
else { nx = -dz; ny = 0; nz = dx; }
|
|
238
|
+
const nl = Math.max(Math.sqrt(nx*nx+ny*ny+nz*nz), 0.01);
|
|
239
|
+
const off = (l.arcIdx || 0) * len * 0.16;
|
|
240
|
+
l.label.position.set(
|
|
241
|
+
(l.a.pos.x+l.b.pos.x)/2 + nx/nl*off,
|
|
242
|
+
(l.a.pos.y+l.b.pos.y)/2 + ny/nl*off + (l.arcIdx ? 0 : 18),
|
|
243
|
+
(l.a.pos.z+l.b.pos.z)/2 + nz/nl*off
|
|
244
|
+
);
|
|
213
245
|
}
|
|
214
246
|
for (const nd of simNodes) { nd.mesh.position.copy(nd.pos); nd.label.position.set(nd.pos.x, nd.pos.y+18, nd.pos.z); }
|
|
215
247
|
}
|
|
@@ -264,8 +296,14 @@ function renderCard() {
|
|
|
264
296
|
const r = GRAPH.relations.find(x => x.id === selected.obj.id);
|
|
265
297
|
if (!r) { card.style.display = 'none'; return; }
|
|
266
298
|
const s = entityMap.get(r.source_id), t = entityMap.get(r.target_id);
|
|
299
|
+
let attrs = {};
|
|
300
|
+
try { attrs = JSON.parse(r.attributes || '{}'); } catch (_) {}
|
|
301
|
+
let attrHtml = '';
|
|
302
|
+
for (const [k, v] of Object.entries(attrs)) attrHtml += '<div class="kv"><b>'+esc(k)+'</b>: '+esc(String(v))+'</div>';
|
|
267
303
|
card.innerHTML = '<h4>'+esc(r.name)+' <span class="tag" style="color:'+RELATION_STYLE[r.category].css+';border-color:'+RELATION_STYLE[r.category].css+'55">'+r.category+'关系</span></h4>' +
|
|
268
|
-
'<div class="kv"><b>'+(s ? esc(s.entity.name) : '?')+'</b> --> <b>'+(t ? esc(t.entity.name) : '?')+'</b></div
|
|
304
|
+
'<div class="kv"><b>'+(s ? esc(s.entity.name) : '?')+'</b> --> <b>'+(t ? esc(t.entity.name) : '?')+'</b></div>' +
|
|
305
|
+
'<div class="kv">id: '+r.id+' 置信度: '+esc(r.confidence||'确证')+(r.source_ref ? ' 来源: '+esc(r.source_ref) : '')+'</div>' +
|
|
306
|
+
(attrHtml || '');
|
|
269
307
|
card.style.display = 'block';
|
|
270
308
|
}
|
|
271
309
|
}
|