local-knowledge-graph 1.10.4 → 1.12.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.
@@ -0,0 +1,230 @@
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
+ aliases: Array.isArray(e.aliases) ? e.aliases.map(String) : [],
141
+ fromId: Number.isInteger(e.id) ? e.id : null,
142
+ });
143
+ }
144
+ for (const r of relList) {
145
+ if (!r || typeof r !== 'object') continue;
146
+ relations.push({
147
+ source: r.source !== undefined ? r.source : r.source_id,
148
+ target: r.target !== undefined ? r.target : r.target_id,
149
+ name: r.name, category: r.category || '互动',
150
+ confidence: r.confidence, source_ref: r.source_ref || '',
151
+ attributes: r.attributes && typeof r.attributes === 'object' ? r.attributes : {},
152
+ });
153
+ }
154
+ } else if (fmt === 'csv') {
155
+ const rows = parseCsvText(content);
156
+ if (!rows.length) return { ok: false, error: 'CSV 为空' };
157
+ let idx = kindIdx(headers(rows[0]));
158
+ // 首行是合法表头则从第2行起读数据;否则整份内容按默认列序解析(首行也是数据)
159
+ const start = idx ? 1 : 0;
160
+ if (!idx) idx = defaultIdx();
161
+ for (let i = start; i < rows.length; i++) {
162
+ const c = rows[i];
163
+ const get = (n) => (idx[n] < c.length ? String(c[idx[n]]).trim() : '');
164
+ const where = `第${i + 1}行`;
165
+ const kind = get('kind');
166
+ if (kind === '实体' || kind === 'entity') {
167
+ entities.push({ name: get('name'), category: get('category') || '抽象实体', attributes: parseAttributesCell(get('attributes'), errors, where), fromId: null });
168
+ } else if (kind === '关系' || kind === 'relation') {
169
+ relations.push({
170
+ source: get('source') || get('name'),
171
+ target: get('target'),
172
+ name: get('relation'),
173
+ category: get('relation_category') || get('category') || '互动',
174
+ confidence: get('confidence') || undefined,
175
+ source_ref: get('source_ref'),
176
+ attributes: parseAttributesCell(get('attributes'), errors, where),
177
+ });
178
+ } else if (kind) {
179
+ warnings.push(`${where}: 未知种类"${kind}",已跳过`);
180
+ }
181
+ }
182
+ } else {
183
+ return { ok: false, error: '格式必须为 csv 或 json' };
184
+ }
185
+
186
+ // 基础合法性过滤(深度校验在入库时进行,逐行收集错误)
187
+ const validEntities = [];
188
+ for (const e of entities) {
189
+ if (!e.name) { errors.push(`实体缺少名称: ${JSON.stringify(e.name)}`); continue; }
190
+ if (!V.ENTITY_CATEGORIES.includes(e.category)) {
191
+ warnings.push(`实体"${e.name}"大类"${e.category}"非法,已按"抽象实体"导入`);
192
+ e.category = '抽象实体';
193
+ }
194
+ validEntities.push(e);
195
+ }
196
+ const validRelations = [];
197
+ for (const r of relations) {
198
+ if (!r.source || !r.target || !r.name) { errors.push(`关系缺少起点/终点/名称: ${JSON.stringify(r)}`); continue; }
199
+ if (!V.RELATION_CATEGORIES.includes(r.category)) {
200
+ warnings.push(`关系"${r.name}"大类"${r.category}"非法,已按"互动"导入`);
201
+ r.category = '互动';
202
+ }
203
+ validRelations.push(r);
204
+ }
205
+ return { ok: true, entities: validEntities, relations: validRelations, errors, warnings };
206
+ }
207
+
208
+ function headers(row) { return row.map((c) => String(c).trim()); }
209
+ function kindIdx(cols) {
210
+ const map = {};
211
+ cols.forEach((c, i) => { map[c] = i; });
212
+ if (map['种类'] === undefined && map['kind'] === undefined) return null;
213
+ return {
214
+ kind: map['种类'] !== undefined ? map['种类'] : map['kind'],
215
+ name: map['名称'] !== undefined ? map['名称'] : (map['name'] !== undefined ? map['name'] : 1),
216
+ category: map['大类'] !== undefined ? map['大类'] : (map['category'] !== undefined ? map['category'] : 2),
217
+ source: map['起点'] !== undefined ? map['起点'] : (map['source'] !== undefined ? map['source'] : 3),
218
+ target: map['终点'] !== undefined ? map['终点'] : (map['target'] !== undefined ? map['target'] : 4),
219
+ relation: map['关系'] !== undefined ? map['关系'] : (map['relation'] !== undefined ? map['relation'] : 5),
220
+ relation_category: map['关系大类'] !== undefined ? map['关系大类'] : 6,
221
+ confidence: map['置信度'] !== undefined ? map['置信度'] : (map['confidence'] !== undefined ? map['confidence'] : 7),
222
+ source_ref: map['来源引用'] !== undefined ? map['来源引用'] : (map['source_ref'] !== undefined ? map['source_ref'] : 8),
223
+ attributes: map['属性'] !== undefined ? map['属性'] : (map['attributes'] !== undefined ? map['attributes'] : 9),
224
+ };
225
+ }
226
+ function defaultIdx() {
227
+ return { kind: 0, name: 1, category: 2, source: 3, target: 4, relation: 5, relation_category: 6, confidence: 7, source_ref: 8, attributes: 9 };
228
+ }
229
+
230
+ 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
- for (const [k, v] of Object.entries(attributes)) {
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,11 +173,52 @@ 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;
157
183
  }
158
184
 
185
+ // ---- 修复回执(Archify 式):把本包定义的校验错误文案映射为稳定规则码与允许的修复动作 ----
186
+ // 错误文案全部由本包抛出(validator/db),正则匹配可靠;新增校验规则时同步在此登记
187
+ const RECEIPT_RULES = [
188
+ { re: /实体大类必须为/, code: 'E_ENTITY_CATEGORY', fixes: ['category 改为以下之一: 物理实体 / 抽象实体 / 数值实体 / 时间实体'] },
189
+ { re: /关系大类必须为/, code: 'E_RELATION_CATEGORY', fixes: ['category 改为以下之一: 空间 / 互动 / 归属 / 时间 / 属性'] },
190
+ { re: /置信度必须为/, code: 'E_CONFIDENCE', fixes: ['confidence 改为以下之一: 确证 / 推测 / 存疑;省略该字段时默认确证'] },
191
+ { re: /禁止嵌套对象或数组/, code: 'E_ATTR_NESTED', fixes: ['attributes 的值只能是字符串/数值/布尔/空值;把嵌套结构拆成多个扁平键,或改为一段说明文本'] },
192
+ { re: /必须为JSON对象/, code: 'E_ATTR_TYPE', fixes: ['attributes 必须是JSON对象(如 {"键":"值"});无属性时省略该字段'] },
193
+ { re: /起点实体id=\d+ 不存在/, code: 'E_SOURCE_MISSING', fixes: ['先用 {"op":"add_entity",...,"ref":"X"} 创建该实体,再用 "source_ref":"X" 引用;或改用图谱中已存在实体的数字id'] },
194
+ { re: /终点实体id=\d+ 不存在/, code: 'E_TARGET_MISSING', fixes: ['先用 {"op":"add_entity",...,"ref":"X"} 创建该实体,再用 "target_ref":"X" 引用;或改用图谱中已存在实体的数字id'] },
195
+ { re: /起点与终点不能为同一实体/, code: 'E_SELF_LOOP', fixes: ['source 与 target 不能相同;确需表达自反关系时改为实体属性或拆分为两个实体'] },
196
+ { re: /重复三元组|已存在同名/, code: 'E_DUPLICATE_RELATION', fixes: ['同起点+终点+关系名的组合只能存在一条:删除本条 add_relation,需要修改时改用 update_relation,或换一个关系名'] },
197
+ { re: /(实体|关系)名称必须为非空字符串/, code: 'E_NAME_EMPTY', fixes: ['name 必须为非空字符串'] },
198
+ { re: /长度不得超过200字符/, code: 'E_NAME_TOO_LONG', fixes: ['name 截断到 200 字符以内'] },
199
+ { re: /未知操作类型/, code: 'E_UNKNOWN_OP', fixes: ['op 仅支持: add_entity / add_relation / update_entity / delete_entity / update_relation / delete_relation'] },
200
+ { re: /ref必须为非空字符串/, code: 'E_REF_INVALID', fixes: ['ref 必须为非空字符串占位符,如 "ref":"A"'] },
201
+ { re: /引用的实体不存在/, code: 'E_REF_UNRESOLVED', fixes: ['ref 未在本次操作的 add_entity 中定义:先输出对应 add_entity(带相同 ref),或改用数字id'] },
202
+ ];
203
+
204
+ // 构造单条修复回执:index 从1开始对应操作序号;evidence 为出错操作原文(截断)
205
+ function buildReceipt(index, op, err) {
206
+ const msg = String((err && err.message) || err);
207
+ const rule = RECEIPT_RULES.find((r) => r.re.test(msg));
208
+ let evidence = '';
209
+ try { evidence = JSON.stringify(op).slice(0, 300); } catch (_) { evidence = String(op).slice(0, 300); }
210
+ const subjectSrc = op && (op.name !== undefined ? op.name : (op.ref !== undefined ? op.ref : op.id));
211
+ return {
212
+ index,
213
+ op_kind: (op && op.op) || '未知',
214
+ code: rule ? rule.code : 'E_UNKNOWN',
215
+ subject: subjectSrc !== undefined ? String(subjectSrc) : '',
216
+ message: msg,
217
+ evidence,
218
+ supportedFixes: rule ? rule.fixes : ['核对该操作是否符合 RDF 规范(大类/扁平属性/实体引用)后重试'],
219
+ };
220
+ }
221
+
159
222
  module.exports = {
160
223
  ENTITY_CATEGORIES,
161
224
  RELATION_CATEGORIES,
@@ -167,4 +230,7 @@ module.exports = {
167
230
  validateEntityPatch,
168
231
  validateRelationInput,
169
232
  validateRelationPatch,
233
+ findDuplicateRelation,
234
+ RECEIPT_RULES,
235
+ buildReceipt,
170
236
  };
@@ -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 = st.dashed
171
- ? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize||6, gapSize: st.gapSize||4, transparent: true, opacity: st.opacity===undefined?0.9:st.opacity })
172
- : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: st.opacity===undefined?0.9:st.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: st.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
- l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
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> --&gt; <b>'+(t ? esc(t.entity.name) : '?')+'</b></div><div class="kv">id: '+r.id+' 来源: '+esc(r.source)+'</div>';
304
+ '<div class="kv"><b>'+(s ? esc(s.entity.name) : '?')+'</b> --&gt; <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
  }
@@ -278,6 +316,29 @@ function resize() {
278
316
  }
279
317
  window.addEventListener('resize', resize);
280
318
  resize();
319
+
320
+ // 深链:#entity=<id> 打开时自动选中并飞向该实体(与主应用链接格式一致)
321
+ (function applyHash() {
322
+ const m = location.hash.match(/^#entity=(\d+)$/);
323
+ if (!m) return;
324
+ const id = Number(m[1]);
325
+ if (!entityMap.has(id)) return;
326
+ selected.obj = { type: 'entity', id };
327
+ renderCard();
328
+ const nd = simNodes.find(n => n.id === id);
329
+ if (nd) {
330
+ const dir = camera.position.clone().sub(controls.target).normalize();
331
+ const from = camera.position.clone(), fromT = controls.target.clone(), to = nd.pos.clone().add(dir.multiplyScalar(110));
332
+ let t = 0;
333
+ (function fly() {
334
+ t = Math.min(1, t + 0.04);
335
+ const k = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2)/2;
336
+ camera.position.lerpVectors(from, to, k);
337
+ controls.target.lerpVectors(fromT, nd.pos, k);
338
+ if (t < 1) requestAnimationFrame(fly);
339
+ })();
340
+ }
341
+ })();
281
342
  </script>
282
343
  </body>
283
344
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-knowledge-graph",
3
- "version": "1.10.4",
3
+ "version": "1.12.0",
4
4
  "description": "本地知识图谱整合器:3D可视化 + AI对话建图 + 智能检索 + Git回溯 + RDF合规,全流程本地运行",
5
5
  "main": "server.js",
6
6
  "bin": {