local-knowledge-graph 1.5.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/.opencode/skill/kg-triples/SKILL.md +55 -0
- package/HELP.md +112 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.js +86 -0
- package/lib/agent.js +211 -0
- package/lib/ask.js +290 -0
- package/lib/db.js +613 -0
- package/lib/embeddings.js +166 -0
- package/lib/git.js +175 -0
- package/lib/importer.js +54 -0
- package/lib/inference.js +123 -0
- package/lib/paths.js +53 -0
- package/lib/rdf.js +64 -0
- package/lib/updater.js +144 -0
- package/lib/validator.js +146 -0
- package/lib/vectors.js +68 -0
- package/lib/viewer.js +33 -0
- package/lib/viewer_template.html +283 -0
- package/mcp/server.js +262 -0
- package/package.json +44 -0
- package/public/app.js +1761 -0
- package/public/index.html +238 -0
- package/public/style.css +374 -0
- package/public/vendor/OrbitControls.js +1045 -0
- package/public/vendor/three.min.js +6 -0
- package/server.js +625 -0
- package/tools/ego.js +122 -0
- package/tools/search.js +101 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* ================= 全局状态 ================= */
|
|
4
|
+
const state = {
|
|
5
|
+
entities: [], relations: [],
|
|
6
|
+
entityMap: new Map(),
|
|
7
|
+
version: -1,
|
|
8
|
+
editingEntityId: null,
|
|
9
|
+
selected: null, // {type:'entity'|'relation'|'inferred', id}(relation为正id,inferred为负id)
|
|
10
|
+
ego: null, // {centerId, depth: null=全部|正整数} 中心层级模式
|
|
11
|
+
showInferred: false, // 是否叠加显示推理关系
|
|
12
|
+
inferredData: null, // /api/inference 缓存 {inferred, entities, ontology}
|
|
13
|
+
imageCounts: new Map(), // entityId -> 图片数量
|
|
14
|
+
entityImages: new Map(), // entityId -> [图片行]
|
|
15
|
+
meta: null, // /api/meta 缓存(图例与下拉框用)
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const $ = (id) => document.getElementById(id);
|
|
19
|
+
function toast(msg, isErr) {
|
|
20
|
+
const t = $('toast');
|
|
21
|
+
t.textContent = msg;
|
|
22
|
+
t.className = isErr ? 'err' : '';
|
|
23
|
+
t.style.display = 'block';
|
|
24
|
+
clearTimeout(t._timer);
|
|
25
|
+
t._timer = setTimeout(() => { t.style.display = 'none'; }, 3200);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ================= 常量样式映射 ================= */
|
|
29
|
+
const ENTITY_STYLE = {
|
|
30
|
+
'物理实体': { color: 0x4fc3f7, css: '#4fc3f7', shape: '实心球', size: 1 },
|
|
31
|
+
'抽象实体': { color: 0xba68c8, css: '#ba68c8', shape: '线框球', size: 1 },
|
|
32
|
+
'数值实体': { color: 0x81c784, css: '#81c784', shape: '立方体', size: 1 },
|
|
33
|
+
'时间实体': { color: 0xffb74d, css: '#ffb74d', shape: '圆环', size: 1 },
|
|
34
|
+
};
|
|
35
|
+
const RELATION_STYLE = {
|
|
36
|
+
'空间': { color: 0x4caf50, css: '#4caf50', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
37
|
+
'互动': { color: 0xf44336, css: '#f44336', dashed: true, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
38
|
+
'归属': { color: 0x2196f3, css: '#2196f3', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
39
|
+
'时间': { color: 0xffc107, css: '#ffc107', dashed: true, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
40
|
+
'属性': { color: 0x9c27b0, css: '#9c27b0', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/* ================= 主题自定义(视图设置面板持久化) ================= */
|
|
44
|
+
const ENTITY_STYLE_BASE = JSON.parse(JSON.stringify(ENTITY_STYLE));
|
|
45
|
+
const RELATION_STYLE_BASE = JSON.parse(JSON.stringify(RELATION_STYLE));
|
|
46
|
+
const THEME_KEY = 'kg_theme_v1';
|
|
47
|
+
const THEME_SIZE_MIN = 0.5, THEME_SIZE_MAX = 2.2;
|
|
48
|
+
|
|
49
|
+
function hexToInt(h) { return parseInt(String(h).replace('#', ''), 16) || 0x888888; }
|
|
50
|
+
function clampThemeSize(v) { return Math.min(THEME_SIZE_MAX, Math.max(THEME_SIZE_MIN, Number(v) || 1)); }
|
|
51
|
+
function loadTheme() { try { return JSON.parse(localStorage.getItem(THEME_KEY)); } catch (_) { return null; } }
|
|
52
|
+
|
|
53
|
+
function applyTheme(t) {
|
|
54
|
+
if (!t) return false;
|
|
55
|
+
try {
|
|
56
|
+
for (const cat of Object.keys(ENTITY_STYLE_BASE)) {
|
|
57
|
+
const o = t.entity && t.entity[cat];
|
|
58
|
+
if (!o) continue;
|
|
59
|
+
ENTITY_STYLE[cat].css = o.color;
|
|
60
|
+
ENTITY_STYLE[cat].color = hexToInt(o.color);
|
|
61
|
+
ENTITY_STYLE[cat].size = clampThemeSize(o.size);
|
|
62
|
+
}
|
|
63
|
+
for (const cat of Object.keys(RELATION_STYLE_BASE)) {
|
|
64
|
+
const o = t.relation && t.relation[cat];
|
|
65
|
+
if (!o) continue;
|
|
66
|
+
RELATION_STYLE[cat].css = o.color;
|
|
67
|
+
RELATION_STYLE[cat].color = hexToInt(o.color);
|
|
68
|
+
RELATION_STYLE[cat].dashed = !!o.dashed;
|
|
69
|
+
RELATION_STYLE[cat].dashSize = Math.max(1, Number(o.dashSize) || 6);
|
|
70
|
+
RELATION_STYLE[cat].gapSize = Math.max(1, Number(o.gapSize) || 4);
|
|
71
|
+
RELATION_STYLE[cat].opacity = Math.min(1, Math.max(0.15, Number(o.opacity) || 0.9));
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
} catch (_) { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resetThemeToBase() {
|
|
78
|
+
for (const k of Object.keys(ENTITY_STYLE)) Object.assign(ENTITY_STYLE[k], ENTITY_STYLE_BASE[k]);
|
|
79
|
+
for (const k of Object.keys(RELATION_STYLE)) Object.assign(RELATION_STYLE[k], RELATION_STYLE_BASE[k]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function currentThemeJson() {
|
|
83
|
+
return {
|
|
84
|
+
entity: Object.fromEntries(Object.entries(ENTITY_STYLE).map(([k, v]) => [k, { color: v.css, size: v.size }])),
|
|
85
|
+
relation: Object.fromEntries(Object.entries(RELATION_STYLE).map(([k, v]) => [k, { color: v.css, dashed: v.dashed, dashSize: v.dashSize, gapSize: v.gapSize, opacity: v.opacity }])),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// 中心层级模式:层级光圈配色(L0中心金色双环,L1起按层递进,超出循环)
|
|
89
|
+
const LEVEL_COLORS = ['#ffd75f', '#7ee787', '#58a6ff', '#d2a8ff', '#ffa657', '#ff7b72', '#e3b341'];
|
|
90
|
+
const levelColor = (lv) => LEVEL_COLORS[Math.min(lv, LEVEL_COLORS.length - 1)];
|
|
91
|
+
|
|
92
|
+
function makeRingSprite(cssColor, isCenter) {
|
|
93
|
+
const size = 128;
|
|
94
|
+
const canvas = document.createElement('canvas');
|
|
95
|
+
canvas.width = canvas.height = size;
|
|
96
|
+
const ctx = canvas.getContext('2d');
|
|
97
|
+
ctx.strokeStyle = cssColor;
|
|
98
|
+
if (isCenter) {
|
|
99
|
+
ctx.lineWidth = 9; ctx.globalAlpha = 0.95;
|
|
100
|
+
ctx.beginPath(); ctx.arc(size / 2, size / 2, 52, 0, Math.PI * 2); ctx.stroke();
|
|
101
|
+
ctx.lineWidth = 4; ctx.globalAlpha = 0.6;
|
|
102
|
+
ctx.beginPath(); ctx.arc(size / 2, size / 2, 36, 0, Math.PI * 2); ctx.stroke();
|
|
103
|
+
} else {
|
|
104
|
+
ctx.lineWidth = 7; ctx.globalAlpha = 0.85;
|
|
105
|
+
ctx.beginPath(); ctx.arc(size / 2, size / 2, 50, 0, Math.PI * 2); ctx.stroke();
|
|
106
|
+
}
|
|
107
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
108
|
+
tex.minFilter = THREE.LinearFilter;
|
|
109
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
|
110
|
+
sprite.scale.set(36, 36, 1);
|
|
111
|
+
return sprite;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ================= Three.js 场景 ================= */
|
|
115
|
+
const wrap = $('canvas-wrap');
|
|
116
|
+
const scene = new THREE.Scene();
|
|
117
|
+
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 5000);
|
|
118
|
+
camera.position.set(0, 90, 260);
|
|
119
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
120
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
121
|
+
renderer.setClearColor(0x000000, 0);
|
|
122
|
+
wrap.appendChild(renderer.domElement);
|
|
123
|
+
|
|
124
|
+
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
125
|
+
controls.enableDamping = true;
|
|
126
|
+
controls.dampingFactor = 0.08;
|
|
127
|
+
|
|
128
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
|
129
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
130
|
+
dirLight.position.set(120, 200, 100);
|
|
131
|
+
scene.add(dirLight);
|
|
132
|
+
const grid = new THREE.GridHelper(480, 48, 0x1c2a47, 0x141e35);
|
|
133
|
+
grid.position.y = -60;
|
|
134
|
+
scene.add(grid);
|
|
135
|
+
|
|
136
|
+
let nodeGroup = new THREE.Group();
|
|
137
|
+
let linkGroup = new THREE.Group();
|
|
138
|
+
let labelGroup = new THREE.Group();
|
|
139
|
+
scene.add(nodeGroup, linkGroup, labelGroup);
|
|
140
|
+
|
|
141
|
+
const simNodes = []; // { id, pos:Vector3, vel:Vector3, mesh, radius }
|
|
142
|
+
const simLinks = []; // { id, a, b, line, label, dashed }
|
|
143
|
+
let simBudget = 0;
|
|
144
|
+
let simFrame = 0; // 隔帧斥力计数
|
|
145
|
+
let settleCount = 0; // 连续安静帧数,达45帧判定布局收敛并休眠模拟
|
|
146
|
+
let camFly = null; // 相机飞行动画状态(搜索点击聚焦;声明须在animate()首调之前)
|
|
147
|
+
|
|
148
|
+
function resize() {
|
|
149
|
+
const w = wrap.clientWidth, h = wrap.clientHeight;
|
|
150
|
+
camera.aspect = w / h;
|
|
151
|
+
camera.updateProjectionMatrix();
|
|
152
|
+
renderer.setSize(w, h);
|
|
153
|
+
}
|
|
154
|
+
window.addEventListener('resize', resize);
|
|
155
|
+
|
|
156
|
+
/* 跨断点自适应:桌面(>1100) / 紧凑桌面(769-1100) / 手机(<=768),切换时自动纠正布局状态 */
|
|
157
|
+
let wasMobileView = window.matchMedia('(max-width: 768px)').matches;
|
|
158
|
+
window.addEventListener('resize', () => {
|
|
159
|
+
const m = window.matchMedia('(max-width: 768px)').matches;
|
|
160
|
+
if (m !== wasMobileView) {
|
|
161
|
+
wasMobileView = m;
|
|
162
|
+
closeDrawers();
|
|
163
|
+
$('legend').classList.toggle('hidden', m);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function makeLabelSprite(text, cssColor, fontSize) {
|
|
168
|
+
const canvas = document.createElement('canvas');
|
|
169
|
+
const ctx = canvas.getContext('2d');
|
|
170
|
+
const font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`;
|
|
171
|
+
ctx.font = font;
|
|
172
|
+
const w = Math.ceil(ctx.measureText(text).width) + 20;
|
|
173
|
+
canvas.width = w;
|
|
174
|
+
canvas.height = fontSize + 16;
|
|
175
|
+
ctx.font = font;
|
|
176
|
+
ctx.fillStyle = 'rgba(9,13,24,0.78)';
|
|
177
|
+
const r = 8;
|
|
178
|
+
ctx.beginPath();
|
|
179
|
+
ctx.moveTo(r, 0); ctx.lineTo(w - r, 0); ctx.quadraticCurveTo(w, 0, w, r);
|
|
180
|
+
ctx.lineTo(w, canvas.height - r); ctx.quadraticCurveTo(w, canvas.height, w - r, canvas.height);
|
|
181
|
+
ctx.lineTo(r, canvas.height); ctx.quadraticCurveTo(0, canvas.height, 0, canvas.height - r);
|
|
182
|
+
ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0);
|
|
183
|
+
ctx.fill();
|
|
184
|
+
ctx.strokeStyle = cssColor; ctx.globalAlpha = 0.55; ctx.stroke(); ctx.globalAlpha = 1;
|
|
185
|
+
ctx.fillStyle = cssColor;
|
|
186
|
+
ctx.textBaseline = 'middle';
|
|
187
|
+
ctx.fillText(text, 10, canvas.height / 2 + 1);
|
|
188
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
189
|
+
tex.minFilter = THREE.LinearFilter;
|
|
190
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
|
191
|
+
const s = 0.16;
|
|
192
|
+
sprite.scale.set(canvas.width * s, canvas.height * s, 1);
|
|
193
|
+
return sprite;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildNodeMesh(category) {
|
|
197
|
+
const st = ENTITY_STYLE[category] || { color: 0xaaaaaa };
|
|
198
|
+
let mesh;
|
|
199
|
+
if (category === '物理实体') {
|
|
200
|
+
mesh = new THREE.Mesh(new THREE.SphereGeometry(9, 26, 18),
|
|
201
|
+
new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.35, metalness: 0.15 }));
|
|
202
|
+
} else if (category === '抽象实体') {
|
|
203
|
+
mesh = new THREE.Mesh(new THREE.SphereGeometry(10, 18, 12),
|
|
204
|
+
new THREE.MeshBasicMaterial({ color: st.color, wireframe: true }));
|
|
205
|
+
} else if (category === '数值实体') {
|
|
206
|
+
mesh = new THREE.Mesh(new THREE.BoxGeometry(13, 13, 13),
|
|
207
|
+
new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.4, metalness: 0.1 }));
|
|
208
|
+
} else {
|
|
209
|
+
mesh = new THREE.Mesh(new THREE.TorusGeometry(9.5, 3.4, 16, 42),
|
|
210
|
+
new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.35, metalness: 0.15 }));
|
|
211
|
+
}
|
|
212
|
+
mesh.userData.category = category;
|
|
213
|
+
mesh.scale.setScalar(ENTITY_STYLE[category].size || 1);
|
|
214
|
+
return mesh;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function rebuildGraph() {
|
|
218
|
+
scene.remove(nodeGroup, linkGroup, labelGroup);
|
|
219
|
+
nodeGroup = new THREE.Group(); linkGroup = new THREE.Group(); labelGroup = new THREE.Group();
|
|
220
|
+
scene.add(nodeGroup, linkGroup, labelGroup);
|
|
221
|
+
simNodes.length = 0; simLinks.length = 0;
|
|
222
|
+
|
|
223
|
+
// 中心层级模式:仅构建子图;全图模式:构建全部
|
|
224
|
+
const sub = state.ego ? calcEgo(state.ego.centerId, state.ego.depth) : null;
|
|
225
|
+
const ents = sub ? sub.entities : state.entities;
|
|
226
|
+
const rels = sub ? sub.relations : state.relations;
|
|
227
|
+
|
|
228
|
+
state.entityMap.clear();
|
|
229
|
+
const N = ents.length;
|
|
230
|
+
ents.forEach((e, i) => {
|
|
231
|
+
const mesh = buildNodeMesh(e.category);
|
|
232
|
+
if (sub) {
|
|
233
|
+
// 层级球壳分布:中心固定原点,每层外扩
|
|
234
|
+
if (e.level === 0) {
|
|
235
|
+
mesh.position.set(0, 0, 0);
|
|
236
|
+
} else {
|
|
237
|
+
const shell = 26 + e.level * 54;
|
|
238
|
+
const ringNodes = ents.filter((x) => x.level === e.level);
|
|
239
|
+
const k = ringNodes.indexOf(e) + 0.5;
|
|
240
|
+
const phi = Math.acos(1 - 2 * k / Math.max(ringNodes.length, 1));
|
|
241
|
+
const theta = Math.PI * (1 + Math.sqrt(5)) * k;
|
|
242
|
+
mesh.position.set(
|
|
243
|
+
shell * Math.sin(phi) * Math.cos(theta),
|
|
244
|
+
(shell * 0.6) * Math.cos(phi),
|
|
245
|
+
shell * Math.sin(phi) * Math.sin(theta)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
// 层级光圈:中心金色双环,其余按层级配色
|
|
249
|
+
mesh.add(makeRingSprite(levelColor(e.level), e.level === 0));
|
|
250
|
+
} else {
|
|
251
|
+
// 初始位置:斐波那契球面分布
|
|
252
|
+
const k = i + 0.5;
|
|
253
|
+
const phi = Math.acos(1 - 2 * k / Math.max(N, 1));
|
|
254
|
+
const theta = Math.PI * (1 + Math.sqrt(5)) * k;
|
|
255
|
+
const R = 40 + 12 * Math.sqrt(N);
|
|
256
|
+
mesh.position.set(
|
|
257
|
+
R * Math.sin(phi) * Math.cos(theta),
|
|
258
|
+
(R * 0.6) * Math.cos(phi),
|
|
259
|
+
R * Math.sin(phi) * Math.sin(theta)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
mesh.userData.entityId = e.id;
|
|
263
|
+
nodeGroup.add(mesh);
|
|
264
|
+
|
|
265
|
+
let attrs = {};
|
|
266
|
+
try { attrs = JSON.parse(e.attributes || '{}'); } catch (_) {}
|
|
267
|
+
const lvlTag = sub ? ` L${e.level}` : '';
|
|
268
|
+
const label = makeLabelSprite(e.name + lvlTag, ENTITY_STYLE[e.category] ? ENTITY_STYLE[e.category].css : '#ccc', 34);
|
|
269
|
+
label.userData.text = e.name + lvlTag;
|
|
270
|
+
labelGroup.add(label);
|
|
271
|
+
|
|
272
|
+
state.entityMap.set(e.id, { entity: e, mesh, label, attrs, level: sub ? e.level : null });
|
|
273
|
+
simNodes.push({ id: e.id, pos: mesh.position.clone(), vel: new THREE.Vector3(), mesh, label, radius: 10, sizeScale: ENTITY_STYLE[e.category] ? (ENTITY_STYLE[e.category].size || 1) : 1 });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
rels.forEach((r) => {
|
|
277
|
+
const a = simNodes.find((n) => n.id === r.source_id);
|
|
278
|
+
const b = simNodes.find((n) => n.id === r.target_id);
|
|
279
|
+
if (!a || !b) return;
|
|
280
|
+
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
281
|
+
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
282
|
+
const op = st.opacity === undefined ? 0.9 : st.opacity;
|
|
283
|
+
const mat = st.dashed
|
|
284
|
+
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
|
|
285
|
+
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
286
|
+
const line = new THREE.Line(geo, mat);
|
|
287
|
+
line.userData.relationId = r.id;
|
|
288
|
+
linkGroup.add(line);
|
|
289
|
+
const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
|
|
290
|
+
// 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
|
|
291
|
+
const lbl = makeLabelSprite(r.name, st.css, 24);
|
|
292
|
+
lbl.userData.text = r.name;
|
|
293
|
+
lbl.position.copy(mid);
|
|
294
|
+
labelGroup.add(lbl);
|
|
295
|
+
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed: st.dashed });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
|
|
299
|
+
if (state.showInferred && state.inferredData) {
|
|
300
|
+
const ids = new Set(ents.map((e) => e.id));
|
|
301
|
+
state.inferredData.inferred.forEach((ir, idx) => {
|
|
302
|
+
if (!ids.has(ir.source_id) || !ids.has(ir.target_id)) return;
|
|
303
|
+
const a = simNodes.find((n) => n.id === ir.source_id);
|
|
304
|
+
const b = simNodes.find((n) => n.id === ir.target_id);
|
|
305
|
+
if (!a || !b) return;
|
|
306
|
+
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
307
|
+
const mat = new THREE.LineDashedMaterial({ color: 0xc792ea, dashSize: 3, gapSize: 5, transparent: true, opacity: 0.35 });
|
|
308
|
+
const line = new THREE.Line(geo, mat);
|
|
309
|
+
line.computeLineDistances();
|
|
310
|
+
line.userData.relationId = -(idx + 1);
|
|
311
|
+
linkGroup.add(line);
|
|
312
|
+
const lbl = makeLabelSprite(ir.name + '(推)', '#c792ea', 22);
|
|
313
|
+
lbl.position.copy(a.pos.clone().add(b.pos).multiplyScalar(0.5));
|
|
314
|
+
labelGroup.add(lbl);
|
|
315
|
+
simLinks.push({ id: line.userData.relationId, a, b, line, label: lbl, dashed: true });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
simBudget = 420;
|
|
320
|
+
settleCount = 0;
|
|
321
|
+
$('empty-hint').style.display = N ? 'none' : 'block';
|
|
322
|
+
if (sub) $('empty-hint').innerHTML = '该范围内暂无实体';
|
|
323
|
+
renderInfoCard();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function simStep() {
|
|
327
|
+
const n = simNodes.length;
|
|
328
|
+
const REP = 2600, SPRING = 0.01, REST = 78, CENTER = 0.006, DAMP = 0.86;
|
|
329
|
+
// 大图隔帧斥力:>240节点时偶数帧复用上一帧斥力,近似减半计算量
|
|
330
|
+
const skipRep = n > 240 && (simFrame++ % 2 === 1);
|
|
331
|
+
if (!skipRep) {
|
|
332
|
+
for (let i = 0; i < n; i++) {
|
|
333
|
+
for (let j = i + 1; j < n; j++) {
|
|
334
|
+
const A = simNodes[i], B = simNodes[j];
|
|
335
|
+
const dx = A.pos.x - B.pos.x, dy = A.pos.y - B.pos.y, dz = A.pos.z - B.pos.z;
|
|
336
|
+
let d2 = dx * dx + dy * dy + dz * dz;
|
|
337
|
+
if (d2 < 4) d2 = 4;
|
|
338
|
+
const d = Math.sqrt(d2);
|
|
339
|
+
const f = REP / d2;
|
|
340
|
+
const fx = (dx / d) * f, fy = (dy / d) * f, fz = (dz / d) * f;
|
|
341
|
+
A.vel.x += fx; A.vel.y += fy; A.vel.z += fz;
|
|
342
|
+
B.vel.x -= fx; B.vel.y -= fy; B.vel.z -= fz;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const l of simLinks) {
|
|
347
|
+
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;
|
|
348
|
+
const d = Math.max(Math.sqrt(dx * dx + dy * dy + dz * dz), 0.01);
|
|
349
|
+
const f = SPRING * (d - REST);
|
|
350
|
+
const fx = (dx / d) * f, fy = (dy / d) * f, fz = (dz / d) * f;
|
|
351
|
+
l.a.vel.x += fx; l.a.vel.y += fy; l.a.vel.z += fz;
|
|
352
|
+
l.b.vel.x -= fx; l.b.vel.y -= fy; l.b.vel.z -= fz;
|
|
353
|
+
}
|
|
354
|
+
for (const nd of simNodes) {
|
|
355
|
+
nd.vel.multiplyScalar(DAMP);
|
|
356
|
+
nd.vel.addScaledVector(nd.pos, -CENTER);
|
|
357
|
+
nd.pos.add(nd.vel);
|
|
358
|
+
// 收敛检测:本帧有节点位移明显则重置安静计数
|
|
359
|
+
if (nd.vel.lengthSq() > 0.09) settleCount = 0;
|
|
360
|
+
}
|
|
361
|
+
settleCount++;
|
|
362
|
+
for (const l of simLinks) {
|
|
363
|
+
const posAttr = l.line.geometry.attributes.position;
|
|
364
|
+
posAttr.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
|
|
365
|
+
posAttr.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
|
|
366
|
+
posAttr.needsUpdate = true;
|
|
367
|
+
if (l.dashed) l.line.computeLineDistances();
|
|
368
|
+
l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
|
|
369
|
+
}
|
|
370
|
+
for (const nd of simNodes) {
|
|
371
|
+
nd.mesh.position.copy(nd.pos);
|
|
372
|
+
nd.label.position.set(nd.pos.x, nd.pos.y + 18, nd.pos.z);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function animate() {
|
|
377
|
+
requestAnimationFrame(animate);
|
|
378
|
+
if (simBudget > 0 && settleCount < 45) { simStep(); simBudget--; }
|
|
379
|
+
// 选中实体呼吸高亮(叠加自定义尺寸系数)
|
|
380
|
+
for (const nd of simNodes) nd.mesh.scale.setScalar(nd.sizeScale || 1);
|
|
381
|
+
if (state.selected && state.selected.type === 'entity') {
|
|
382
|
+
const m = state.entityMap.get(state.selected.id);
|
|
383
|
+
if (m) {
|
|
384
|
+
const nd = simNodes.find((n) => n.id === state.selected.id);
|
|
385
|
+
const base = (nd && nd.sizeScale) || 1;
|
|
386
|
+
m.mesh.scale.setScalar(base * (1.25 + 0.08 * Math.sin(Date.now() / 300)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// 相机飞行(搜索点击聚焦):目标点跟随节点当前位置,easeInOutQuad插值
|
|
390
|
+
if (camFly) {
|
|
391
|
+
camFly.t = Math.min(1, camFly.t + 0.03);
|
|
392
|
+
const x = camFly.t;
|
|
393
|
+
const k = x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
|
394
|
+
const to = new THREE.Vector3(camFly.nd.pos.x, camFly.nd.pos.y, camFly.nd.pos.z);
|
|
395
|
+
camera.position.lerpVectors(camFly.fromPos, to.clone().addScaledVector(camFly.dir, camFly.dist), k);
|
|
396
|
+
controls.target.lerpVectors(camFly.fromTarget, to, k);
|
|
397
|
+
if (camFly.t >= 1) camFly = null;
|
|
398
|
+
}
|
|
399
|
+
controls.update();
|
|
400
|
+
renderer.render(scene, camera);
|
|
401
|
+
}
|
|
402
|
+
animate();
|
|
403
|
+
|
|
404
|
+
/* ================= 拾取 ================= */
|
|
405
|
+
const raycaster = new THREE.Raycaster();
|
|
406
|
+
raycaster.params.Line = { threshold: 3 };
|
|
407
|
+
let downPos = null;
|
|
408
|
+
renderer.domElement.addEventListener('pointerdown', (e) => { downPos = { x: e.clientX, y: e.clientY }; camFly = null; });
|
|
409
|
+
renderer.domElement.addEventListener('pointerup', (e) => {
|
|
410
|
+
if (!downPos) return;
|
|
411
|
+
const moved = Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y);
|
|
412
|
+
downPos = null;
|
|
413
|
+
const tapThresh = e.pointerType === 'touch' ? 12 : 5;
|
|
414
|
+
if (moved > tapThresh) return;
|
|
415
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
416
|
+
const mouse = new THREE.Vector2(
|
|
417
|
+
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
418
|
+
-((e.clientY - rect.top) / rect.height) * 2 + 1
|
|
419
|
+
);
|
|
420
|
+
raycaster.setFromCamera(mouse, camera);
|
|
421
|
+
const meshHits = raycaster.intersectObjects(nodeGroup.children, false);
|
|
422
|
+
if (meshHits.length) {
|
|
423
|
+
state.selected = { type: 'entity', id: meshHits[0].object.userData.entityId };
|
|
424
|
+
renderInfoCard();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const lineHits = raycaster.intersectObjects(linkGroup.children, false);
|
|
428
|
+
if (lineHits.length) {
|
|
429
|
+
state.selected = { type: 'relation', id: lineHits[0].object.userData.relationId };
|
|
430
|
+
renderInfoCard();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
state.selected = null;
|
|
434
|
+
renderInfoCard();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
/* ================= 节点悬浮提示 ================= */
|
|
438
|
+
const tooltipEl = $('tooltip3d');
|
|
439
|
+
let hoverPending = null;
|
|
440
|
+
renderer.domElement.addEventListener('pointermove', (e) => {
|
|
441
|
+
if (e.pointerType === 'touch') { tooltipEl.style.display = 'none'; return; }
|
|
442
|
+
hoverPending = { x: e.clientX, y: e.clientY };
|
|
443
|
+
});
|
|
444
|
+
setInterval(() => {
|
|
445
|
+
if (!hoverPending) return;
|
|
446
|
+
const { x, y } = hoverPending;
|
|
447
|
+
hoverPending = null;
|
|
448
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
449
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { tooltipEl.style.display = 'none'; return; }
|
|
450
|
+
const mouse = new THREE.Vector2(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1);
|
|
451
|
+
raycaster.setFromCamera(mouse, camera);
|
|
452
|
+
const hits = raycaster.intersectObjects(nodeGroup.children, false);
|
|
453
|
+
if (!hits.length) { tooltipEl.style.display = 'none'; renderer.domElement.style.cursor = ''; return; }
|
|
454
|
+
const m = state.entityMap.get(hits[0].object.userData.entityId);
|
|
455
|
+
if (!m) { tooltipEl.style.display = 'none'; return; }
|
|
456
|
+
let attrs = '';
|
|
457
|
+
try {
|
|
458
|
+
const a = typeof m.entity.attributes === 'string' ? JSON.parse(m.entity.attributes || '{}') : (m.entity.attributes || {});
|
|
459
|
+
const k = Object.keys(a)[0];
|
|
460
|
+
if (k) attrs = `<div class="tt-attr">${escapeHtml(k)}: ${escapeHtml(String(a[k]).slice(0, 40))}</div>`;
|
|
461
|
+
} catch (_) { /* 属性非JSON时省略 */ }
|
|
462
|
+
tooltipEl.innerHTML = `<b>${escapeHtml(m.entity.name)}</b><span class="tt-cat">${escapeHtml(m.entity.category)}</span>${attrs}`;
|
|
463
|
+
tooltipEl.style.display = 'block';
|
|
464
|
+
tooltipEl.style.left = Math.min(x + 14, window.innerWidth - 170) + 'px';
|
|
465
|
+
tooltipEl.style.top = (y + 14) + 'px';
|
|
466
|
+
renderer.domElement.style.cursor = 'pointer';
|
|
467
|
+
}, 60);
|
|
468
|
+
|
|
469
|
+
function renderInfoCard() {
|
|
470
|
+
const card = $('info-card');
|
|
471
|
+
if (!state.selected) { card.style.display = 'none'; return; }
|
|
472
|
+
if (state.selected.type === 'entity') {
|
|
473
|
+
const m = state.entityMap.get(state.selected.id);
|
|
474
|
+
if (!m) { card.style.display = 'none'; return; }
|
|
475
|
+
const e = m.entity;
|
|
476
|
+
let attrHtml = '';
|
|
477
|
+
for (const [k, v] of Object.entries(m.attrs)) attrHtml += `<div class="kv"><b>${escapeHtml(k)}</b>: ${escapeHtml(String(v))}</div>`;
|
|
478
|
+
const relsInScope = (state.ego ? calcEgo(state.ego.centerId, state.ego.depth).relations : state.relations);
|
|
479
|
+
const relCount = relsInScope.filter((r) => r.source_id === e.id || r.target_id === e.id).length;
|
|
480
|
+
const imgCount = state.imageCounts.get(e.id) || 0;
|
|
481
|
+
const imgs = state.entityImages.get(e.id);
|
|
482
|
+
let imgHtml = '';
|
|
483
|
+
if (imgCount > 0 || (imgs && imgs.length)) {
|
|
484
|
+
const rows = imgs || [];
|
|
485
|
+
imgHtml = `<div id="img-section"><div class="kv"><b>图片 ${rows.length || imgCount}</b> 张</div><div id="img-grid">` +
|
|
486
|
+
rows.map((im, i) => `<img src="${escapeHtml(im.thumb_url || im.url)}" title="${escapeHtml(im.caption || im.filename)}" onclick="openLightbox(${e.id},${i})">`).join('') +
|
|
487
|
+
(rows.length ? '' : '<span class="kv">加载中…</span>') + '</div></div>';
|
|
488
|
+
}
|
|
489
|
+
const levelHtml = m.level !== null && m.level !== undefined ? `<div class="kv">层级: <b style="color:${levelColor(m.level)}">L${m.level}</b>${m.level === 0 ? '(中心)' : ''}</div>` : '';
|
|
490
|
+
card.innerHTML = `
|
|
491
|
+
<h4>${escapeHtml(e.name)} <span class="tag" style="color:${ENTITY_STYLE[e.category].css};border-color:${ENTITY_STYLE[e.category].css}55">${e.category} · ${ENTITY_STYLE[e.category].shape}</span></h4>
|
|
492
|
+
<div class="kv">id: ${e.id} 来源: ${e.source}</div>
|
|
493
|
+
<div class="kv">创建: ${e.created_at}</div>
|
|
494
|
+
${levelHtml}
|
|
495
|
+
<div class="kv">关联关系: ${relCount} 条</div>
|
|
496
|
+
${attrHtml || '<div class="kv">(无属性)</div>'}
|
|
497
|
+
${imgHtml}
|
|
498
|
+
<div class="btns">
|
|
499
|
+
<button onclick="focusEgo(${e.id})">以此为中心</button>
|
|
500
|
+
<button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
|
|
501
|
+
<button onclick="$('entity-img-input').click()">绑图片</button>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
|
|
504
|
+
card.style.display = 'block';
|
|
505
|
+
if (imgCount > 0 && !imgs) loadEntityImages(e.id);
|
|
506
|
+
} else {
|
|
507
|
+
const rid = state.selected.id;
|
|
508
|
+
if (rid < 0) {
|
|
509
|
+
// 推理边(负id):展示推导规则与依据链
|
|
510
|
+
const ir = state.inferredData && state.inferredData.inferred[-rid - 1];
|
|
511
|
+
if (!ir) { card.style.display = 'none'; return; }
|
|
512
|
+
const ename = (id) => { const m = state.entityMap.get(id); return m ? escapeHtml(m.entity.name) : '#' + id; };
|
|
513
|
+
const relById = new Map(state.relations.map((r) => [r.id, r]));
|
|
514
|
+
const edge = (r) => {
|
|
515
|
+
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
516
|
+
return `${s ? escapeHtml(s.entity.name) : '#' + r.source_id} -${escapeHtml(r.name)}-> ${t ? escapeHtml(t.entity.name) : '#' + r.target_id}`;
|
|
517
|
+
};
|
|
518
|
+
let chain;
|
|
519
|
+
if (ir.rule.startsWith('传递')) chain = ir.via.map((id) => relById.get(id)).filter(Boolean).map(edge).join(' ,再 ');
|
|
520
|
+
else {
|
|
521
|
+
const r = relById.get(ir.via[0]);
|
|
522
|
+
chain = r ? `由显式关系「${edge(r)}」推得反向边` : '';
|
|
523
|
+
}
|
|
524
|
+
card.innerHTML = `
|
|
525
|
+
<h4>${escapeHtml(ir.name)}(推) <span class="tag" style="color:#c792ea;border-color:#c792ea55">${escapeHtml(ir.rule)}</span></h4>
|
|
526
|
+
<div class="kv"><b>${ename(ir.source_id)}</b> ==> <b>${ename(ir.target_id)}</b></div>
|
|
527
|
+
${chain ? `<div class="kv">推导依据: ${chain}</div>` : ''}
|
|
528
|
+
<div class="kv">隐性关系:仅推理展示,数据库中无此记录</div>`;
|
|
529
|
+
card.style.display = 'block';
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const r = state.relations.find((x) => x.id === rid);
|
|
533
|
+
if (!r) { card.style.display = 'none'; return; }
|
|
534
|
+
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
535
|
+
card.innerHTML = `
|
|
536
|
+
<h4>${escapeHtml(r.name)} <span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}关系</span></h4>
|
|
537
|
+
<div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --> <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
|
|
538
|
+
<div class="kv">id: ${r.id} 来源: ${r.source}</div>
|
|
539
|
+
<div class="btns"><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
|
|
540
|
+
card.style.display = 'block';
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function escapeHtml(s) {
|
|
544
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* ================= 中心层级视图 ================= */
|
|
548
|
+
// 前端本地BFS:与后端 /api/graph/ego 语义一致(双向、最短跳数),depth=null=全部层级
|
|
549
|
+
function calcEgo(centerId, depth) {
|
|
550
|
+
const adj = new Map();
|
|
551
|
+
const touch = (id) => { if (!adj.has(id)) adj.set(id, []); };
|
|
552
|
+
for (const r of state.relations) {
|
|
553
|
+
touch(r.source_id); touch(r.target_id);
|
|
554
|
+
adj.get(r.source_id).push(r);
|
|
555
|
+
adj.get(r.target_id).push(r);
|
|
556
|
+
}
|
|
557
|
+
const maxDepth = Number.isInteger(depth) && depth > 0 ? depth : Infinity;
|
|
558
|
+
const level = new Map([[centerId, 0]]);
|
|
559
|
+
let frontier = [centerId];
|
|
560
|
+
while (frontier.length) {
|
|
561
|
+
const next = [];
|
|
562
|
+
for (const id of frontier) {
|
|
563
|
+
const cur = level.get(id);
|
|
564
|
+
if (cur >= maxDepth) continue;
|
|
565
|
+
for (const r of adj.get(id) || []) {
|
|
566
|
+
const other = r.source_id === id ? r.target_id : r.source_id;
|
|
567
|
+
if (!level.has(other)) { level.set(other, cur + 1); next.push(other); }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
frontier = next;
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
entities: state.entities.filter((e) => level.has(e.id)).map((e) => ({ ...e, level: level.get(e.id) })),
|
|
574
|
+
relations: state.relations.filter((r) => level.has(r.source_id) && level.has(r.target_id)),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function updateEgoBar() {
|
|
579
|
+
const bar = $('ego-bar');
|
|
580
|
+
if (!state.ego) { bar.classList.remove('show'); return; }
|
|
581
|
+
const m = state.entityMap.get(state.ego.centerId) || state.entities.find((e) => e.id === state.ego.centerId);
|
|
582
|
+
$('ego-name').textContent = m ? (m.entity ? m.entity.name : m.name) : `#${state.ego.centerId}`;
|
|
583
|
+
$('ego-depth').value = state.ego.depth === null ? '' : String(state.ego.depth);
|
|
584
|
+
bar.classList.add('show');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function focusEgo(id) {
|
|
588
|
+
state.ego = { centerId: id, depth: null };
|
|
589
|
+
updateEgoBar();
|
|
590
|
+
rebuildGraph();
|
|
591
|
+
const m = state.entityMap.get(id);
|
|
592
|
+
toast(m ? `已进入中心模式:${m.entity.name}(全部层级)` : '已进入中心模式');
|
|
593
|
+
}
|
|
594
|
+
window.focusEgo = focusEgo;
|
|
595
|
+
|
|
596
|
+
function exitEgo() {
|
|
597
|
+
state.ego = null;
|
|
598
|
+
updateEgoBar();
|
|
599
|
+
rebuildGraph();
|
|
600
|
+
}
|
|
601
|
+
window.exitEgo = exitEgo;
|
|
602
|
+
|
|
603
|
+
/* 搜索/列表点击聚焦:选中+呼吸高亮+相机平滑飞行;ego视图中无此节点时先退出重建 */
|
|
604
|
+
function focusEntity(id) {
|
|
605
|
+
if (state.ego && !simNodes.some((n) => n.id === id)) {
|
|
606
|
+
state.ego = null;
|
|
607
|
+
updateEgoBar();
|
|
608
|
+
rebuildGraph();
|
|
609
|
+
}
|
|
610
|
+
state.selected = { type: 'entity', id };
|
|
611
|
+
renderInfoCard();
|
|
612
|
+
const nd = simNodes.find((n) => n.id === id);
|
|
613
|
+
if (!nd) return;
|
|
614
|
+
// 沿当前视线方向推进相机,目标点实时跟随节点(模拟仍在收敛时会同步追踪)
|
|
615
|
+
const dir = camera.position.clone().sub(controls.target).normalize();
|
|
616
|
+
camFly = { nd, dir, dist: 110, fromPos: camera.position.clone(), fromTarget: controls.target.clone(), t: 0 };
|
|
617
|
+
simBudget = Math.max(simBudget, 60);
|
|
618
|
+
settleCount = 0;
|
|
619
|
+
}
|
|
620
|
+
window.focusEntity = focusEntity;
|
|
621
|
+
|
|
622
|
+
$('ego-depth').addEventListener('change', () => {
|
|
623
|
+
if (!state.ego) return;
|
|
624
|
+
const raw = $('ego-depth').value.trim();
|
|
625
|
+
if (raw === '') { state.ego.depth = null; }
|
|
626
|
+
else {
|
|
627
|
+
const n = Number(raw);
|
|
628
|
+
if (!Number.isInteger(n) || n <= 0) { toast('层数必须为正整数(清空表示全部层级)', true); updateEgoBar(); return; }
|
|
629
|
+
state.ego.depth = n;
|
|
630
|
+
}
|
|
631
|
+
rebuildGraph();
|
|
632
|
+
});
|
|
633
|
+
$('ego-rebuild').addEventListener('click', () => { if (state.ego) rebuildGraph(); });
|
|
634
|
+
$('ego-exit').addEventListener('click', exitEgo);
|
|
635
|
+
|
|
636
|
+
/* ================= 实体图片绑定与灯箱 ================= */
|
|
637
|
+
const imgInput = document.createElement('input');
|
|
638
|
+
imgInput.type = 'file';
|
|
639
|
+
imgInput.accept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp';
|
|
640
|
+
imgInput.multiple = true;
|
|
641
|
+
imgInput.style.display = 'none';
|
|
642
|
+
imgInput.id = 'entity-img-input';
|
|
643
|
+
document.body.appendChild(imgInput);
|
|
644
|
+
window.$ = $; // 信息卡内联onclick需访问$
|
|
645
|
+
|
|
646
|
+
imgInput.addEventListener('change', async () => {
|
|
647
|
+
const id = state.selected && state.selected.type === 'entity' ? state.selected.id : null;
|
|
648
|
+
const files = [...imgInput.files];
|
|
649
|
+
imgInput.value = '';
|
|
650
|
+
if (!id || !files.length) return;
|
|
651
|
+
for (const f of files) {
|
|
652
|
+
try {
|
|
653
|
+
if (f.size > 10 * 1024 * 1024) throw new Error('超过10MB上限');
|
|
654
|
+
const b64 = await readAsB64(f);
|
|
655
|
+
const thumb = await makeThumbB64(f);
|
|
656
|
+
let caption = '';
|
|
657
|
+
if (files.length === 1) caption = prompt('图片备注(可留空)', '') || '';
|
|
658
|
+
await api(`/api/entities/${id}/images`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, content_b64: b64, caption, thumb_b64: thumb }) });
|
|
659
|
+
toast(`已绑定图片 ${f.name}`);
|
|
660
|
+
} catch (e) { toast(`图片 ${f.name} 绑定失败: ${e.message}`, true); }
|
|
661
|
+
}
|
|
662
|
+
state.entityImages.delete(id);
|
|
663
|
+
const cnt = await api(`/api/entities/${id}/images`);
|
|
664
|
+
state.entityImages.set(id, cnt);
|
|
665
|
+
state.imageCounts.set(id, cnt.length);
|
|
666
|
+
await refreshAll(false);
|
|
667
|
+
if (state.selected && state.selected.id === id) renderInfoCard();
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// canvas 生成 320px 缩略图(jpeg 0.72),失败时返回 null 由服务端回退原图
|
|
671
|
+
async function makeThumbB64(file) {
|
|
672
|
+
try {
|
|
673
|
+
if (!window.createImageBitmap) return null;
|
|
674
|
+
const bmp = await createImageBitmap(file);
|
|
675
|
+
const scale = Math.min(1, 320 / Math.max(bmp.width, bmp.height));
|
|
676
|
+
const c = document.createElement('canvas');
|
|
677
|
+
c.width = Math.max(1, Math.round(bmp.width * scale));
|
|
678
|
+
c.height = Math.max(1, Math.round(bmp.height * scale));
|
|
679
|
+
c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);
|
|
680
|
+
bmp.close && bmp.close();
|
|
681
|
+
return c.toDataURL('image/jpeg', 0.72).split(',')[1];
|
|
682
|
+
} catch (_) { return null; }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function loadEntityImages(entityId) {
|
|
686
|
+
try {
|
|
687
|
+
const rows = await api(`/api/entities/${entityId}/images`);
|
|
688
|
+
state.entityImages.set(entityId, rows);
|
|
689
|
+
if (state.selected && state.selected.type === 'entity' && state.selected.id === entityId) renderInfoCard();
|
|
690
|
+
} catch (e) { toast(e.message, true); }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* ---- 灯箱 ---- */
|
|
694
|
+
let lbState = null; // { list, idx }
|
|
695
|
+
function openLightbox(entityId, idx) {
|
|
696
|
+
const list = state.entityImages.get(entityId) || [];
|
|
697
|
+
if (!list.length) return;
|
|
698
|
+
lbState = { list, idx };
|
|
699
|
+
renderLightbox();
|
|
700
|
+
$('lightbox').classList.add('show');
|
|
701
|
+
}
|
|
702
|
+
window.openLightbox = openLightbox;
|
|
703
|
+
|
|
704
|
+
function renderLightbox() {
|
|
705
|
+
if (!lbState) return;
|
|
706
|
+
const im = lbState.list[lbState.idx];
|
|
707
|
+
$('lb-img').src = im.url;
|
|
708
|
+
$('lb-cap').textContent = (im.caption || im.filename) + `(实体#${im.entity_id})`;
|
|
709
|
+
$('lb-pos').textContent = `${lbState.idx + 1} / ${lbState.list.length}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function closeLightbox() { $('lightbox').classList.remove('show'); lbState = null; }
|
|
713
|
+
$('lb-close').addEventListener('click', closeLightbox);
|
|
714
|
+
$('lightbox').addEventListener('click', (e) => { if (e.target === $('lightbox')) closeLightbox(); });
|
|
715
|
+
$('lb-prev').addEventListener('click', () => { if (lbState) { lbState.idx = (lbState.idx - 1 + lbState.list.length) % lbState.list.length; renderLightbox(); } });
|
|
716
|
+
$('lb-next').addEventListener('click', () => { if (lbState) { lbState.idx = (lbState.idx + 1) % lbState.list.length; renderLightbox(); } });
|
|
717
|
+
document.addEventListener('keydown', (e) => {
|
|
718
|
+
if (!$('lightbox').classList.contains('show')) return;
|
|
719
|
+
if (e.key === 'Escape') closeLightbox();
|
|
720
|
+
if (e.key === 'ArrowLeft') $('lb-prev').click();
|
|
721
|
+
if (e.key === 'ArrowRight') $('lb-next').click();
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
async function delImage() {
|
|
725
|
+
if (!lbState) return;
|
|
726
|
+
const im = lbState.list[lbState.idx];
|
|
727
|
+
if (!confirm(`删除图片绑定「${im.filename}」?文件将从磁盘移除。`)) return;
|
|
728
|
+
try {
|
|
729
|
+
await api(`/api/images/${im.id}`, { method: 'DELETE' });
|
|
730
|
+
const eid = im.entity_id;
|
|
731
|
+
lbState.list.splice(lbState.idx, 1);
|
|
732
|
+
state.imageCounts.set(eid, lbState.list.length);
|
|
733
|
+
if (!lbState.list.length) closeLightbox(); else renderLightbox();
|
|
734
|
+
state.entityImages.set(eid, lbState.list);
|
|
735
|
+
renderEntityList();
|
|
736
|
+
toast('图片绑定已删除');
|
|
737
|
+
if (state.selected && state.selected.id === eid) renderInfoCard();
|
|
738
|
+
} catch (e) { toast(e.message, true); }
|
|
739
|
+
}
|
|
740
|
+
window.delImage = delImage;
|
|
741
|
+
|
|
742
|
+
/* ================= 手机端抽屉 ================= */
|
|
743
|
+
const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
|
|
744
|
+
function openDrawer(id) {
|
|
745
|
+
$(id).classList.add('open');
|
|
746
|
+
$('backdrop').classList.add('show');
|
|
747
|
+
}
|
|
748
|
+
function closeDrawers() {
|
|
749
|
+
$('panel-left').classList.remove('open');
|
|
750
|
+
$('panel-right').classList.remove('open');
|
|
751
|
+
$('backdrop').classList.remove('show');
|
|
752
|
+
}
|
|
753
|
+
$('m-left').addEventListener('click', () => {
|
|
754
|
+
const opened = $('panel-left').classList.contains('open');
|
|
755
|
+
closeDrawers();
|
|
756
|
+
if (!opened) openDrawer('panel-left');
|
|
757
|
+
});
|
|
758
|
+
$('m-right').addEventListener('click', () => {
|
|
759
|
+
const opened = $('panel-right').classList.contains('open');
|
|
760
|
+
closeDrawers();
|
|
761
|
+
if (!opened) openDrawer('panel-right');
|
|
762
|
+
});
|
|
763
|
+
$('backdrop').addEventListener('click', closeDrawers);
|
|
764
|
+
$('m-legend').addEventListener('click', () => $('legend').classList.toggle('hidden'));
|
|
765
|
+
if (isMobile()) $('legend').classList.add('hidden');
|
|
766
|
+
|
|
767
|
+
/* ================= 面板逻辑 ================= */
|
|
768
|
+
function initTabs() {
|
|
769
|
+
$('tabs').addEventListener('click', (e) => {
|
|
770
|
+
const t = e.target.closest('[data-tab]');
|
|
771
|
+
if (!t) return;
|
|
772
|
+
document.querySelectorAll('#tabs div').forEach((d) => d.classList.toggle('active', d === t));
|
|
773
|
+
document.querySelectorAll('.tabbody').forEach((b) => b.classList.toggle('active', b.id === 'tab-' + t.dataset.tab));
|
|
774
|
+
if (t.dataset.tab === 'log') loadLogs();
|
|
775
|
+
if (t.dataset.tab === 'version') loadHistory();
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function api(url, opts) {
|
|
780
|
+
const res = await fetch(url, opts);
|
|
781
|
+
const data = await res.json().catch(() => ({}));
|
|
782
|
+
if (!res.ok) throw new Error(data.error || `请求失败(${res.status})`);
|
|
783
|
+
return data;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function fillCategorySelects(meta) {
|
|
787
|
+
state.meta = meta;
|
|
788
|
+
const eSel = $('e-category'), rSel = $('r-category');
|
|
789
|
+
eSel.innerHTML = meta.entity_categories.map((c) => `<option value="${c}">${c}(${ENTITY_STYLE[c].shape})</option>`).join('');
|
|
790
|
+
rSel.innerHTML = meta.relation_categories.map((c) => `<option value="${c}">${c}关系</option>`).join('');
|
|
791
|
+
renderLegend();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function renderLegend() {
|
|
795
|
+
const meta = state.meta;
|
|
796
|
+
if (!meta) return;
|
|
797
|
+
$('legend').innerHTML = '<b>实体样式</b><br>' +
|
|
798
|
+
meta.entity_categories.map((c) => `<span class="sw" style="background:${ENTITY_STYLE[c].css}"></span>${c} · ${ENTITY_STYLE[c].shape}`).join('<br>') +
|
|
799
|
+
'<br><b>关系线型</b><br>' +
|
|
800
|
+
meta.relation_categories.map((c) => `<span class="ln ${RELATION_STYLE[c].dashed ? 'dash' : ''}" style="border-color:${RELATION_STYLE[c].css}"></span>${c}关系`).join('<br>');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function refreshEntityOptions() {
|
|
804
|
+
const opts = state.entities.map((e) => `<option value="${e.id}">#${e.id} ${escapeHtml(e.name)}(${e.category})</option>`).join('');
|
|
805
|
+
$('r-source').innerHTML = opts || '<option value="">(请先创建实体)</option>';
|
|
806
|
+
$('r-target').innerHTML = opts || '<option value="">(请先创建实体)</option>';
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function parseAttrs(text) {
|
|
810
|
+
const t = (text || '').trim();
|
|
811
|
+
if (!t) return {};
|
|
812
|
+
const obj = JSON.parse(t);
|
|
813
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) throw new Error('属性必须是JSON对象');
|
|
814
|
+
return obj;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function submitEntity() {
|
|
818
|
+
try {
|
|
819
|
+
const body = {
|
|
820
|
+
name: $('e-name').value.trim(),
|
|
821
|
+
category: $('e-category').value,
|
|
822
|
+
attributes: parseAttrs($('e-attrs').value),
|
|
823
|
+
};
|
|
824
|
+
if (!body.name) return toast('请输入实体名称', true);
|
|
825
|
+
if (state.editingEntityId) {
|
|
826
|
+
await api(`/api/entities/${state.editingEntityId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
827
|
+
toast('实体已更新');
|
|
828
|
+
} else {
|
|
829
|
+
await api('/api/entities', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
830
|
+
toast('实体已添加');
|
|
831
|
+
}
|
|
832
|
+
cancelEditEntity();
|
|
833
|
+
await refreshAll();
|
|
834
|
+
} catch (e) { toast(e.message, true); }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function editEntity(id) {
|
|
838
|
+
const m = state.entityMap.get(id);
|
|
839
|
+
if (!m) return;
|
|
840
|
+
state.editingEntityId = id;
|
|
841
|
+
$('entity-form-title').textContent = `编辑实体 #${id}`;
|
|
842
|
+
$('e-name').value = m.entity.name;
|
|
843
|
+
$('e-category').value = m.entity.category;
|
|
844
|
+
$('e-attrs').value = JSON.stringify(m.attrs, null, 2);
|
|
845
|
+
$('e-submit').textContent = '保存修改';
|
|
846
|
+
$('e-cancel').style.display = '';
|
|
847
|
+
document.querySelector('[data-tab="entity"]').click();
|
|
848
|
+
if (isMobile()) openDrawer('panel-left');
|
|
849
|
+
}
|
|
850
|
+
window.editEntity = editEntity;
|
|
851
|
+
|
|
852
|
+
function cancelEditEntity() {
|
|
853
|
+
state.editingEntityId = null;
|
|
854
|
+
$('entity-form-title').textContent = '新增实体';
|
|
855
|
+
$('e-name').value = '';
|
|
856
|
+
$('e-attrs').value = '';
|
|
857
|
+
$('e-submit').textContent = '添加';
|
|
858
|
+
$('e-cancel').style.display = 'none';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function delEntity(id) {
|
|
862
|
+
const m = state.entityMap.get(id);
|
|
863
|
+
if (!m) return;
|
|
864
|
+
if (!confirm(`删除实体「${m.entity.name}」?其关联关系将一并删除。`)) return;
|
|
865
|
+
try {
|
|
866
|
+
await api(`/api/entities/${id}`, { method: 'DELETE' });
|
|
867
|
+
if (state.selected && state.selected.id === id) state.selected = null;
|
|
868
|
+
toast('实体已删除');
|
|
869
|
+
await refreshAll();
|
|
870
|
+
} catch (e) { toast(e.message, true); }
|
|
871
|
+
}
|
|
872
|
+
window.delEntity = delEntity;
|
|
873
|
+
|
|
874
|
+
async function submitRelation() {
|
|
875
|
+
try {
|
|
876
|
+
const body = {
|
|
877
|
+
source_id: Number($('r-source').value),
|
|
878
|
+
target_id: Number($('r-target').value),
|
|
879
|
+
name: $('r-name').value.trim(),
|
|
880
|
+
category: $('r-category').value,
|
|
881
|
+
};
|
|
882
|
+
if (!body.name) return toast('请输入关系名称', true);
|
|
883
|
+
if (!body.source_id || !body.target_id) return toast('请先创建实体', true);
|
|
884
|
+
await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
885
|
+
$('r-name').value = '';
|
|
886
|
+
toast('关系已添加');
|
|
887
|
+
await refreshAll();
|
|
888
|
+
} catch (e) { toast(e.message, true); }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function delRelation(id) {
|
|
892
|
+
if (!confirm(`删除关系 #${id}?`)) return;
|
|
893
|
+
try {
|
|
894
|
+
await api(`/api/relations/${id}`, { method: 'DELETE' });
|
|
895
|
+
if (state.selected && state.selected.id === id) state.selected = null;
|
|
896
|
+
toast('关系已删除');
|
|
897
|
+
await refreshAll();
|
|
898
|
+
} catch (e) { toast(e.message, true); }
|
|
899
|
+
}
|
|
900
|
+
window.delRelation = delRelation;
|
|
901
|
+
|
|
902
|
+
function renderEntityList() {
|
|
903
|
+
$('e-list').innerHTML = state.entities.map((e) => {
|
|
904
|
+
const ic = state.imageCounts.get(e.id) || 0;
|
|
905
|
+
return `
|
|
906
|
+
<div class="list-item">
|
|
907
|
+
<div class="main">
|
|
908
|
+
<div class="name">${escapeHtml(e.name)}<span class="tag" style="color:${ENTITY_STYLE[e.category].css};border-color:${ENTITY_STYLE[e.category].css}55">${e.category}</span>${ic ? `<span class="tag img-tag">图 ${ic}</span>` : ''}</div>
|
|
909
|
+
<div class="sub">#${e.id} · ${e.source} · ${Object.keys(state.entityMap.get(e.id) ? state.entityMap.get(e.id).attrs : {}).length}个属性</div>
|
|
910
|
+
</div>
|
|
911
|
+
<button onclick="editEntity(${e.id})">编辑</button>
|
|
912
|
+
<button class="danger" onclick="delEntity(${e.id})">删</button>
|
|
913
|
+
</div>`;
|
|
914
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">暂无实体</div>';
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function renderRelationList() {
|
|
918
|
+
$('r-list').innerHTML = state.relations.map((r) => {
|
|
919
|
+
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
920
|
+
return `
|
|
921
|
+
<div class="list-item">
|
|
922
|
+
<div class="main">
|
|
923
|
+
<div class="name">${escapeHtml(r.name)}<span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}</span></div>
|
|
924
|
+
<div class="sub">#${r.id} · ${s ? escapeHtml(s.entity.name) : '?'} → ${t ? escapeHtml(t.entity.name) : '?'} · ${r.source}</div>
|
|
925
|
+
</div>
|
|
926
|
+
<button class="danger" onclick="delRelation(${r.id})">删</button>
|
|
927
|
+
</div>`;
|
|
928
|
+
}).join('') || '<div class="sub" style="color:#5c6f92">暂无关系</div>';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/* 日志人话渲染:op_type + snapshot JSON → 可读中文;已删实体名称回退#id */
|
|
932
|
+
const OP_LABELS = {
|
|
933
|
+
ADD_ENTITY: '新增实体', UPDATE_ENTITY: '更新实体', DELETE_ENTITY: '删除实体',
|
|
934
|
+
ADD_RELATION: '新增关系', UPDATE_RELATION: '更新关系', DELETE_RELATION: '删除关系', RESTORE: '版本回溯',
|
|
935
|
+
};
|
|
936
|
+
function entName(id) {
|
|
937
|
+
const m = state.entityMap.get(id);
|
|
938
|
+
return m ? `「${m.entity.name}」` : `#${id}`;
|
|
939
|
+
}
|
|
940
|
+
function clip(s, n = 28) {
|
|
941
|
+
s = String(s ?? '');
|
|
942
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
943
|
+
}
|
|
944
|
+
function fmtEntity(e) {
|
|
945
|
+
return e ? `「${e.name}」(${e.category})` : '';
|
|
946
|
+
}
|
|
947
|
+
function diffFields(before, after) {
|
|
948
|
+
if (!before || !after) return '';
|
|
949
|
+
const changed = Object.keys(after).filter((k) => k !== 'id' && String(before[k] ?? '') !== String(after[k] ?? ''));
|
|
950
|
+
return changed.map((k) => `${k}→${clip(after[k])}`).join(',');
|
|
951
|
+
}
|
|
952
|
+
function formatLog(l) {
|
|
953
|
+
let s = {};
|
|
954
|
+
try { s = JSON.parse(l.snapshot); } catch (_) { /* 旧格式快照原样展示 */ }
|
|
955
|
+
switch (l.op_type) {
|
|
956
|
+
case 'ADD_ENTITY': return `新增实体 ${fmtEntity(s.entity)},来源 ${s.entity?.source ?? '—'}`;
|
|
957
|
+
case 'UPDATE_ENTITY': return `更新实体 ${fmtEntity(s.after)}:${diffFields(s.before, s.after) || '无字段变化'}`;
|
|
958
|
+
case 'DELETE_ENTITY': return `删除实体 ${fmtEntity(s.entity)}${s.cascaded_relations ? `,级联删除 ${s.cascaded_relations} 条关系` : ''}`;
|
|
959
|
+
case 'ADD_RELATION': return `新增关系 ${entName(s.relation?.source_id)} —[${s.relation?.name}]→ ${entName(s.relation?.target_id)}(${s.relation?.category ?? '—'})`;
|
|
960
|
+
case 'UPDATE_RELATION': return `更新关系 #${s.after?.id ?? '?'} [${s.after?.name ?? '?'}]:${diffFields(s.before, s.after) || '无字段变化'}`;
|
|
961
|
+
case 'DELETE_RELATION': return `删除关系 ${entName(s.relation?.source_id)} —[${s.relation?.name}]→ ${entName(s.relation?.target_id)}${s.reason ? `(${s.reason})` : ''}`;
|
|
962
|
+
case 'RESTORE': return `版本回溯至 ${s.restored_to ?? '?'},回溯后 实体 ${s.counts?.entities ?? '?'} / 关系 ${s.counts?.relations ?? '?'}`;
|
|
963
|
+
default: return clip(l.snapshot, 160);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
let logsCache = [];
|
|
968
|
+
function renderLogs() {
|
|
969
|
+
const type = $('log-type').value;
|
|
970
|
+
const kw = $('log-kw').value.trim().toLowerCase();
|
|
971
|
+
const shown = logsCache.filter((l) => {
|
|
972
|
+
if (type && l.op_type !== type) return false;
|
|
973
|
+
if (kw) {
|
|
974
|
+
const hay = (formatLog(l) + ' ' + l.source + ' ' + (OP_LABELS[l.op_type] || l.op_type)).toLowerCase();
|
|
975
|
+
if (!hay.includes(kw)) return false;
|
|
976
|
+
}
|
|
977
|
+
return true;
|
|
978
|
+
});
|
|
979
|
+
$('log-list').innerHTML = shown.map((l) => `
|
|
980
|
+
<div class="log-item">
|
|
981
|
+
<div class="l1"><span>#${l.id} ${OP_LABELS[l.op_type] || escapeHtml(l.op_type)}</span><span>${escapeHtml(l.source)}</span></div>
|
|
982
|
+
<div class="l1"><span style="color:#54617f">${l.created_at}</span></div>
|
|
983
|
+
<div class="l2">${escapeHtml(formatLog(l))}</div>
|
|
984
|
+
</div>`).join('') || `<div class="sub" style="color:#5c6f92">${logsCache.length ? '无匹配日志' : '暂无日志'}</div>`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function loadLogs() {
|
|
988
|
+
try {
|
|
989
|
+
logsCache = await api('/api/logs?limit=200');
|
|
990
|
+
renderLogs();
|
|
991
|
+
} catch (e) { toast(e.message, true); }
|
|
992
|
+
}
|
|
993
|
+
$('log-type').addEventListener('change', renderLogs);
|
|
994
|
+
$('log-kw').addEventListener('input', renderLogs);
|
|
995
|
+
|
|
996
|
+
async function loadHistory() {
|
|
997
|
+
try {
|
|
998
|
+
const list = await api('/api/git/history');
|
|
999
|
+
$('v-list').innerHTML = list.map((c, i) => `
|
|
1000
|
+
<div class="ver-item">
|
|
1001
|
+
<div class="v1"><code>${c.short}</code><span style="color:#7ee787;font-size:10px">${i === 0 ? '当前' : ''}</span>
|
|
1002
|
+
<span style="flex:1"></span>
|
|
1003
|
+
${i === 0 ? '' : `<button onclick="restoreTo('${c.hash}')">恢复此版本</button>`}
|
|
1004
|
+
</div>
|
|
1005
|
+
<div class="v2">${escapeHtml(c.title)}</div>
|
|
1006
|
+
<div class="v3">${c.date} · ${escapeHtml(c.source || c.author)}${c.log_range ? ` · 覆盖日志 #${c.log_range[0]}-#${c.log_range[1]}` : ''}</div>
|
|
1007
|
+
</div>`).join('') || '<div class="sub" style="color:#5c6f92">暂无保存点</div>';
|
|
1008
|
+
} catch (e) { toast(e.message, true); }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function restoreTo(hash) {
|
|
1012
|
+
if (!confirm(`回溯到保存点 ${hash.slice(0, 8)}?当前未保存的修改会先自动备份。`)) return;
|
|
1013
|
+
try {
|
|
1014
|
+
await api('/api/git/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }) });
|
|
1015
|
+
toast('已回溯到保存点 ' + hash.slice(0, 8));
|
|
1016
|
+
state.selected = null;
|
|
1017
|
+
await refreshAll();
|
|
1018
|
+
loadHistory();
|
|
1019
|
+
} catch (e) { toast(e.message, true); }
|
|
1020
|
+
}
|
|
1021
|
+
window.restoreTo = restoreTo;
|
|
1022
|
+
|
|
1023
|
+
async function doSavepoint(message) {
|
|
1024
|
+
try {
|
|
1025
|
+
const r = await api('/api/git/savepoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) });
|
|
1026
|
+
toast(r.committed ? `保存点已创建: ${r.hash}` : r.message);
|
|
1027
|
+
$('v-message').value = '';
|
|
1028
|
+
loadHistory();
|
|
1029
|
+
} catch (e) { toast(e.message, true); }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/* ================= 撤销最近操作(快照逆向还原) ================= */
|
|
1033
|
+
async function doUndo() {
|
|
1034
|
+
try {
|
|
1035
|
+
const r = await api('/api/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
1036
|
+
let msg = r.summary || '已撤销';
|
|
1037
|
+
if (r.caveats && r.caveats.length) msg += '(' + r.caveats.join(';') + ')';
|
|
1038
|
+
toast(msg);
|
|
1039
|
+
state.selected = null;
|
|
1040
|
+
renderInfoCard();
|
|
1041
|
+
await refreshAll();
|
|
1042
|
+
loadLogs();
|
|
1043
|
+
loadHistory();
|
|
1044
|
+
} catch (e) { toast(e.message, true); }
|
|
1045
|
+
}
|
|
1046
|
+
$('btn-undo').addEventListener('click', doUndo);
|
|
1047
|
+
|
|
1048
|
+
/* ================= 键盘快捷键 ================= */
|
|
1049
|
+
// Esc逐层关闭浮层 → 清除选中 → 退出中心模式;Delete删除选中;Ctrl+Z撤销;Ctrl+F跳转检索
|
|
1050
|
+
function isTypingContext() {
|
|
1051
|
+
const el = document.activeElement;
|
|
1052
|
+
if (!el) return false;
|
|
1053
|
+
const tag = el.tagName;
|
|
1054
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
document.addEventListener('keydown', (e) => {
|
|
1058
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
1059
|
+
if (mod && (e.key === 'z' || e.key === 'Z')) {
|
|
1060
|
+
if (isTypingContext()) return;
|
|
1061
|
+
e.preventDefault();
|
|
1062
|
+
doUndo();
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (mod && (e.key === 'f' || e.key === 'F')) {
|
|
1066
|
+
e.preventDefault();
|
|
1067
|
+
document.querySelector('[data-tab="search"]').click();
|
|
1068
|
+
$('s-query').focus();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (isTypingContext()) return;
|
|
1072
|
+
if (e.key === 'Escape') {
|
|
1073
|
+
if ($('help-panel').classList.contains('show')) {
|
|
1074
|
+
$('help-panel').classList.remove('show');
|
|
1075
|
+
$('info-card').style.display = '';
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if ($('style-panel').classList.contains('show')) { $('style-panel').classList.remove('show'); return; }
|
|
1079
|
+
if ($('file-menu').classList.contains('show')) { $('file-menu').classList.remove('show'); return; }
|
|
1080
|
+
if (state.selected) { state.selected = null; renderInfoCard(); return; }
|
|
1081
|
+
if (state.ego) exitEgo();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && state.selected) {
|
|
1085
|
+
e.preventDefault();
|
|
1086
|
+
if (state.selected.type === 'entity') delEntity(state.selected.id);
|
|
1087
|
+
else delRelation(state.selected.id);
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
/* ================= 最短路径查询 ================= */
|
|
1092
|
+
function renderPathPanel(r) {
|
|
1093
|
+
const panel = $('path-panel');
|
|
1094
|
+
if (!r.found) {
|
|
1095
|
+
$('path-body').innerHTML = '<div class="kv">两实体间在6层内无连通路径</div>';
|
|
1096
|
+
panel.style.display = 'block';
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const rows = [];
|
|
1100
|
+
r.entities.forEach((ent, i) => {
|
|
1101
|
+
if (i > 0) {
|
|
1102
|
+
const rel = r.relations[i - 1];
|
|
1103
|
+
const dir = rel.source_id === r.entities[i - 1].id ? '→' : '←';
|
|
1104
|
+
rows.push(`<div class="p-rel">—${dir} ${escapeHtml(rel.name)} ${dir === '→' ? '→' : '—'}—</div>`);
|
|
1105
|
+
}
|
|
1106
|
+
rows.push(`<div class="p-ent" onclick="focusEntity(${ent.id}); document.getElementById('path-panel').style.display='none'">${escapeHtml(ent.name)}<span class="tag">${escapeHtml(ent.category)}</span></div>`);
|
|
1107
|
+
});
|
|
1108
|
+
$('path-title').textContent = `最短路径(${r.hops} 跳)`;
|
|
1109
|
+
$('path-body').innerHTML = rows.join('');
|
|
1110
|
+
panel.style.display = 'block';
|
|
1111
|
+
}
|
|
1112
|
+
async function askPath(fromId, fromName) {
|
|
1113
|
+
const to = prompt(`查询「${fromName}」到哪位实体的最短路径?(输入名称或id,最多6层)`, '');
|
|
1114
|
+
if (to === null) return;
|
|
1115
|
+
const key = to.trim();
|
|
1116
|
+
if (!key) return;
|
|
1117
|
+
try {
|
|
1118
|
+
const r = await api(`/api/graph/path?from=${fromId}&to=${encodeURIComponent(key)}`);
|
|
1119
|
+
renderPathPanel(r);
|
|
1120
|
+
} catch (e) { toast(e.message, true); }
|
|
1121
|
+
}
|
|
1122
|
+
window.askPath = askPath;
|
|
1123
|
+
$('path-close').addEventListener('click', () => { $('path-panel').style.display = 'none'; });
|
|
1124
|
+
|
|
1125
|
+
/* ================= OpenCode 对话 ================= */
|
|
1126
|
+
function appendMsg(role, text, chips, chipWarn) {
|
|
1127
|
+
const div = document.createElement('div');
|
|
1128
|
+
div.className = 'msg ' + (role === 'user' ? 'user' : 'bot');
|
|
1129
|
+
const who = role === 'user' ? '我' : role === 'agent' ? 'OpenCode' : '系统';
|
|
1130
|
+
div.innerHTML = `<div class="who">${who}</div><div class="bubble">${escapeHtml(text)}</div>`;
|
|
1131
|
+
if (chips && chips.length) {
|
|
1132
|
+
const ops = document.createElement('div');
|
|
1133
|
+
ops.className = 'ops';
|
|
1134
|
+
ops.innerHTML = chips.map((c) => `<span class="chip ${chipWarn ? 'warn' : ''}">${escapeHtml(c)}</span>`).join('');
|
|
1135
|
+
div.appendChild(ops);
|
|
1136
|
+
}
|
|
1137
|
+
$('messages').appendChild(div);
|
|
1138
|
+
$('messages').scrollTop = $('messages').scrollHeight;
|
|
1139
|
+
return div;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/* ================= 文档附件 ================= */
|
|
1143
|
+
let attachedFile = null;
|
|
1144
|
+
$('agent-file-btn').addEventListener('click', () => $('agent-file').click());
|
|
1145
|
+
$('agent-file').addEventListener('change', () => {
|
|
1146
|
+
const f = $('agent-file').files[0];
|
|
1147
|
+
if (!f) return;
|
|
1148
|
+
if (f.size > 20 * 1024 * 1024) { toast('文件超过20MB上限,请拆分后导入', true); $('agent-file').value = ''; return; }
|
|
1149
|
+
attachedFile = f;
|
|
1150
|
+
$('file-chip-name').textContent = `${f.name}(${(f.size / 1024).toFixed(1)}KB)`;
|
|
1151
|
+
$('file-chip').style.display = 'flex';
|
|
1152
|
+
});
|
|
1153
|
+
$('file-chip-del').addEventListener('click', () => {
|
|
1154
|
+
attachedFile = null;
|
|
1155
|
+
$('agent-file').value = '';
|
|
1156
|
+
$('file-chip').style.display = 'none';
|
|
1157
|
+
});
|
|
1158
|
+
function readAsB64(file) {
|
|
1159
|
+
return new Promise((resolve, reject) => {
|
|
1160
|
+
const r = new FileReader();
|
|
1161
|
+
r.onload = () => resolve(String(r.result).split(',')[1]);
|
|
1162
|
+
r.onerror = () => reject(new Error('文件读取失败'));
|
|
1163
|
+
r.readAsDataURL(file);
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async function sendAgent() {
|
|
1168
|
+
const input = $('agent-input');
|
|
1169
|
+
const text = input.value.trim();
|
|
1170
|
+
if (!text && !attachedFile) return;
|
|
1171
|
+
if (!text && attachedFile) { input.value = `请把《${attachedFile.name}》转化为三元组并融合入库`; }
|
|
1172
|
+
const finalText = input.value.trim();
|
|
1173
|
+
input.value = '';
|
|
1174
|
+
appendMsg('user', attachedFile ? `[附文档: ${attachedFile.name}] ${finalText}` : finalText);
|
|
1175
|
+
const pending = appendMsg('agent', attachedFile
|
|
1176
|
+
? `OpenCode 正在按 kg-triples 技能处理《${attachedFile.name}》(分块抽取与融合,可能需要数分钟)…`
|
|
1177
|
+
: 'OpenCode 正在执行指令(可能包含联网查证,最长等待5分钟)…');
|
|
1178
|
+
$('agent-send').disabled = true;
|
|
1179
|
+
let progressTimer = null;
|
|
1180
|
+
try {
|
|
1181
|
+
let r;
|
|
1182
|
+
if (attachedFile) {
|
|
1183
|
+
// 轮询导入进度,实时更新占位消息
|
|
1184
|
+
progressTimer = setInterval(async () => {
|
|
1185
|
+
try {
|
|
1186
|
+
const p = await api('/api/agent/doc/progress');
|
|
1187
|
+
if (!p.active) return;
|
|
1188
|
+
const stageTxt = p.stage === 'importing' ? `抽取第 ${p.chunk}/${p.chunks} 块` : p.stage === 'savepoint' ? '创建保存点' : '准备中';
|
|
1189
|
+
const bubble = pending.querySelector('.bubble');
|
|
1190
|
+
if (bubble) bubble.textContent = `OpenCode 正在按 kg-triples 技能处理《${p.filename}》:${stageTxt}…`;
|
|
1191
|
+
} catch (_) { /* 进度查询失败静默 */ }
|
|
1192
|
+
}, 1500);
|
|
1193
|
+
const b64 = await readAsB64(attachedFile);
|
|
1194
|
+
r = await api('/api/agent/doc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: attachedFile.name, content_b64: b64, instruction: finalText }) });
|
|
1195
|
+
} else {
|
|
1196
|
+
r = await api('/api/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instruction: finalText }) });
|
|
1197
|
+
}
|
|
1198
|
+
pending.remove();
|
|
1199
|
+
appendMsg('agent', r.report ? (r.report.reply || '(抽取完成)') : (r.reply || '(无文字回复)'));
|
|
1200
|
+
if (r.retried) appendMsg('system', '检测到上次会话异常,已自动改用全新会话重试成功。', ['自动恢复'], true);
|
|
1201
|
+
if (r.report) {
|
|
1202
|
+
const rp = r.report;
|
|
1203
|
+
let html = `<div class="report">导入报告《${escapeHtml(rp.filename)}》· 模式:${rp.mode === 'chunked' ? '分块' : '附件'}<br>` +
|
|
1204
|
+
`处理块: ${rp.chunks_ok}/${rp.chunks} <b>新建实体 ${rp.entities_added}</b> <b>新增关系 ${rp.relations_added}</b> 更新/其他 ${rp.others}`;
|
|
1205
|
+
if (rp.errors && rp.errors.length) html += '<br><span class="rerr">问题: ' + rp.errors.map(escapeHtml).join('<br>') + '</span>';
|
|
1206
|
+
html += '</div>';
|
|
1207
|
+
const div = document.createElement('div');
|
|
1208
|
+
div.className = 'msg system';
|
|
1209
|
+
div.innerHTML = `<div class="who">系统</div>${html}`;
|
|
1210
|
+
$('messages').appendChild(div);
|
|
1211
|
+
$('messages').scrollTop = $('messages').scrollHeight;
|
|
1212
|
+
} else if (r.applied && r.applied.length) {
|
|
1213
|
+
appendMsg('system', `已按RDF规范校验并写入 ${r.applied.length} 项操作:\n` +
|
|
1214
|
+
r.applied.map((a) => `- ${a.op}: ${a.name || ''}#${a.id}`).join('\n'));
|
|
1215
|
+
}
|
|
1216
|
+
if (r.ops_found > 0 && (r.applied_total !== undefined ? !r.applied_total : (!r.applied || !r.applied.length))) {
|
|
1217
|
+
appendMsg('system', `OpenCode 提交了 ${r.ops_found} 项操作,但被RDF校验拦截${r.apply_error ? ':' + r.apply_error : ''}。数据库保持上一个合规版本。`, ['已拦截'], true);
|
|
1218
|
+
} else if (r.apply_error) {
|
|
1219
|
+
appendMsg('system', '部分操作被拦截:' + r.apply_error, ['已拦截'], true);
|
|
1220
|
+
}
|
|
1221
|
+
if (r.parse_error) appendMsg('system', '输出协议解析警告: ' + r.parse_error);
|
|
1222
|
+
const sp = r.savepoint;
|
|
1223
|
+
if (sp && sp.committed) appendMsg('system', `已自动创建保存点 ${sp.hash}(与日志双向绑定)`);
|
|
1224
|
+
await refreshAll();
|
|
1225
|
+
} catch (e) {
|
|
1226
|
+
pending.remove();
|
|
1227
|
+
appendMsg('system', '执行失败: ' + e.message);
|
|
1228
|
+
} finally {
|
|
1229
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
1230
|
+
$('file-chip-del').click();
|
|
1231
|
+
$('agent-send').disabled = false;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/* ================= 视图设置面板(主题自定义,桌面端) ================= */
|
|
1236
|
+
// 主题轻量路径:颜色/大小/线型变化只更新材质与标签sprite,保留力导向布局位置(免全量重建卡顿)
|
|
1237
|
+
function relabel(sprite, css, fontSize) {
|
|
1238
|
+
if (!sprite || !sprite.userData.text) return;
|
|
1239
|
+
const fresh = makeLabelSprite(sprite.userData.text, css, fontSize);
|
|
1240
|
+
fresh.position.copy(sprite.position);
|
|
1241
|
+
labelGroup.remove(sprite);
|
|
1242
|
+
labelGroup.add(fresh);
|
|
1243
|
+
return fresh;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function applyStyleLight(dirty) {
|
|
1247
|
+
for (const cat of dirty.entity) {
|
|
1248
|
+
const st = ENTITY_STYLE[cat];
|
|
1249
|
+
for (const nd of simNodes) {
|
|
1250
|
+
const m = state.entityMap.get(nd.id);
|
|
1251
|
+
if (!m || m.entity.category !== cat) continue;
|
|
1252
|
+
if (nd.mesh.material.color) nd.mesh.material.color.setHex(st.color);
|
|
1253
|
+
nd.sizeScale = st.size || 1;
|
|
1254
|
+
const fresh = relabel(nd.label, st.css, 34);
|
|
1255
|
+
if (fresh) nd.label = fresh;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
for (const cat of dirty.relation) {
|
|
1259
|
+
const st = RELATION_STYLE[cat];
|
|
1260
|
+
const op = st.opacity === undefined ? 0.9 : st.opacity;
|
|
1261
|
+
for (const l of simLinks) {
|
|
1262
|
+
const r = state.relations.find((x) => x.id === l.id);
|
|
1263
|
+
if (!r || r.category !== cat) continue;
|
|
1264
|
+
l.line.material = st.dashed
|
|
1265
|
+
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
|
|
1266
|
+
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
|
|
1267
|
+
if (st.dashed) l.line.computeLineDistances();
|
|
1268
|
+
l.dashed = st.dashed;
|
|
1269
|
+
const fresh = relabel(l.label, st.css, 24);
|
|
1270
|
+
if (fresh) l.label = fresh;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function renderStylePanel() {
|
|
1276
|
+
const p = $('style-panel');
|
|
1277
|
+
const entRows = Object.keys(ENTITY_STYLE).map((c) => `
|
|
1278
|
+
<div class="style-row">
|
|
1279
|
+
<span class="sname">${c}</span>
|
|
1280
|
+
<input type="color" data-sec="entity" data-cat="${c}" data-key="color" value="${ENTITY_STYLE[c].css}">
|
|
1281
|
+
<input type="number" data-sec="entity" data-cat="${c}" data-key="size" min="${THEME_SIZE_MIN}" max="${THEME_SIZE_MAX}" step="0.1" value="${ENTITY_STYLE[c].size}" title="大小倍率">
|
|
1282
|
+
<span style="color:#5c6f92">倍率</span>
|
|
1283
|
+
</div>`).join('');
|
|
1284
|
+
const relRows = Object.keys(RELATION_STYLE).map((c) => `
|
|
1285
|
+
<div class="style-row">
|
|
1286
|
+
<span class="sname">${c}</span>
|
|
1287
|
+
<input type="color" data-sec="relation" data-cat="${c}" data-key="color" value="${RELATION_STYLE[c].css}">
|
|
1288
|
+
<label class="ck"><input type="checkbox" data-sec="relation" data-cat="${c}" data-key="dashed" ${RELATION_STYLE[c].dashed ? 'checked' : ''}>虚线</label>
|
|
1289
|
+
<input type="number" data-sec="relation" data-cat="${c}" data-key="dashSize" min="1" step="1" value="${RELATION_STYLE[c].dashSize}" title="虚线段长">
|
|
1290
|
+
<input type="number" data-sec="relation" data-cat="${c}" data-key="gapSize" min="1" step="1" value="${RELATION_STYLE[c].gapSize}" title="虚线间隔">
|
|
1291
|
+
<input type="range" data-sec="relation" data-cat="${c}" data-key="opacity" min="0.15" max="1" step="0.05" value="${RELATION_STYLE[c].opacity}" title="不透明度">
|
|
1292
|
+
</div>`).join('');
|
|
1293
|
+
p.innerHTML = `
|
|
1294
|
+
<h4>视图设置<span id="style-close" title="关闭">x</span></h4>
|
|
1295
|
+
<div class="style-sec">实体 · 颜色 / 大小倍率</div>${entRows}
|
|
1296
|
+
<div class="style-sec">关系 · 颜色 / 虚线 / 段长 / 间隔 / 不透明度</div>${relRows}
|
|
1297
|
+
<div class="row">
|
|
1298
|
+
<button class="primary" id="style-save">保存主题</button>
|
|
1299
|
+
<button class="ghost" id="style-reset">恢复默认</button>
|
|
1300
|
+
</div>
|
|
1301
|
+
<div class="hint-text">修改即时生效并应用于3D画布与图例;「保存主题」写入浏览器本地存储。受WebGL限制线宽恒为1px,虚线可通过段长/间隔调节密度。</div>`;
|
|
1302
|
+
$('style-close').addEventListener('click', () => $('style-panel').classList.remove('show'));
|
|
1303
|
+
$('style-save').addEventListener('click', () => {
|
|
1304
|
+
localStorage.setItem(THEME_KEY, JSON.stringify(currentThemeJson()));
|
|
1305
|
+
toast('主题已保存到本地');
|
|
1306
|
+
});
|
|
1307
|
+
$('style-reset').addEventListener('click', () => {
|
|
1308
|
+
localStorage.removeItem(THEME_KEY);
|
|
1309
|
+
resetThemeToBase();
|
|
1310
|
+
renderLegend();
|
|
1311
|
+
renderStylePanel();
|
|
1312
|
+
rebuildGraph();
|
|
1313
|
+
toast('已恢复默认样式');
|
|
1314
|
+
});
|
|
1315
|
+
const dirty = { entity: new Set(), relation: new Set() };
|
|
1316
|
+
p.querySelectorAll('input').forEach((inp) => {
|
|
1317
|
+
inp.addEventListener('input', () => {
|
|
1318
|
+
const sec = inp.dataset.sec, cat = inp.dataset.cat, key = inp.dataset.key;
|
|
1319
|
+
const target = sec === 'entity' ? ENTITY_STYLE[cat] : RELATION_STYLE[cat];
|
|
1320
|
+
if (key === 'dashed') target.dashed = inp.checked;
|
|
1321
|
+
else if (key === 'color') { target.css = inp.value; target.color = hexToInt(inp.value); }
|
|
1322
|
+
else if (key === 'size') target.size = clampThemeSize(inp.value);
|
|
1323
|
+
else if (key === 'opacity') target.opacity = Number(inp.value);
|
|
1324
|
+
else target[key] = Math.max(1, Number(inp.value) || 6);
|
|
1325
|
+
dirty[sec].add(cat);
|
|
1326
|
+
clearTimeout(p._t);
|
|
1327
|
+
// 面板全部参数均可轻量生效(材质+标签原位更新,保留布局位置)
|
|
1328
|
+
p._t = setTimeout(() => { renderLegend(); applyStyleLight(dirty); dirty.entity.clear(); dirty.relation.clear(); }, 250);
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
$('btn-style').addEventListener('click', () => {
|
|
1334
|
+
const p = $('style-panel');
|
|
1335
|
+
const opening = !p.classList.contains('show');
|
|
1336
|
+
if (opening) { $('info-card').style.display = 'none'; renderStylePanel(); }
|
|
1337
|
+
p.classList.toggle('show');
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
/* ================= 版本与更新 ================= */
|
|
1341
|
+
const UPD = { currentVer: null, last: null, checking: false, applying: false, restarting: false };
|
|
1342
|
+
|
|
1343
|
+
function updBoxHTML() {
|
|
1344
|
+
return `<div class="upd-box">
|
|
1345
|
+
<div class="upd-ver">当前版本 <b class="js-upd-cur">${UPD.currentVer || '…'}</b></div>
|
|
1346
|
+
<div class="row" style="gap:6px;margin:6px 0 4px">
|
|
1347
|
+
<button class="js-upd-check">检查更新</button>
|
|
1348
|
+
<button class="js-upd-apply primary" disabled>一键更新</button>
|
|
1349
|
+
</div>
|
|
1350
|
+
<div class="js-upd-status upd-status">点击"检查更新"联网获取最新版本</div>
|
|
1351
|
+
</div>`;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function wireUpdBox(root) {
|
|
1355
|
+
root.querySelector('.js-upd-check').addEventListener('click', doUpdateCheck);
|
|
1356
|
+
root.querySelector('.js-upd-apply').addEventListener('click', doUpdateApply);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function mountHelpPanel() {
|
|
1360
|
+
const box = $('help-upd');
|
|
1361
|
+
if (box && !box.querySelector('.upd-box')) {
|
|
1362
|
+
box.innerHTML = updBoxHTML();
|
|
1363
|
+
wireUpdBox(box);
|
|
1364
|
+
}
|
|
1365
|
+
renderUpdState();
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function renderUpdState() {
|
|
1369
|
+
document.querySelectorAll('.upd-box').forEach((box) => {
|
|
1370
|
+
box.querySelector('.js-upd-cur').textContent = UPD.currentVer || '…';
|
|
1371
|
+
const st = box.querySelector('.js-upd-status');
|
|
1372
|
+
const check = box.querySelector('.js-upd-check');
|
|
1373
|
+
const apply = box.querySelector('.js-upd-apply');
|
|
1374
|
+
check.disabled = UPD.checking || UPD.applying;
|
|
1375
|
+
apply.disabled = !(UPD.last && UPD.last.ok && !UPD.last.up_to_date && UPD.last.behind > 0) || UPD.checking || UPD.applying;
|
|
1376
|
+
st.innerHTML = updStatusHTML();
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function updStatusHTML() {
|
|
1381
|
+
if (UPD.restarting) return `<span style="color:#7fd8a4">已更新,服务重启中,页面将自动刷新…</span>`;
|
|
1382
|
+
if (UPD.applying) return '正在下载并应用更新,请勿关闭应用…';
|
|
1383
|
+
if (UPD.checking) return '正在检查更新…';
|
|
1384
|
+
if (!UPD.last) return '点击"检查更新"联网获取最新版本';
|
|
1385
|
+
const l = UPD.last;
|
|
1386
|
+
if (!l.ok) return `<span style="color:#e08a8a">${escapeHtml(l.error || '检查失败')}</span>`;
|
|
1387
|
+
const rel = l.latest_release ? `,最新发布 ${escapeHtml(l.latest_release.tag || '')}` : '';
|
|
1388
|
+
if (l.up_to_date) return `<span style="color:#7fd8a4">已是最新版本</span>${rel}`;
|
|
1389
|
+
let s = `<span style="color:#e0c068">发现新版本(落后 ${l.behind} 个提交)</span>${rel}`;
|
|
1390
|
+
if (l.latest_release && l.latest_release.url) s += ` · <a href="${l.latest_release.url}" target="_blank" rel="noopener">查看说明</a>`;
|
|
1391
|
+
return s;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async function doUpdateCheck() {
|
|
1395
|
+
if (UPD.checking || UPD.applying) return;
|
|
1396
|
+
UPD.checking = true;
|
|
1397
|
+
renderUpdState();
|
|
1398
|
+
try {
|
|
1399
|
+
const v = await api('/api/version');
|
|
1400
|
+
UPD.currentVer = v.version;
|
|
1401
|
+
UPD.last = await api('/api/update/check');
|
|
1402
|
+
} catch (e) {
|
|
1403
|
+
UPD.last = { ok: false, error: e.message };
|
|
1404
|
+
}
|
|
1405
|
+
UPD.checking = false;
|
|
1406
|
+
renderUpdState();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async function doUpdateApply() {
|
|
1410
|
+
if (!UPD.last || UPD.last.up_to_date || UPD.applying || UPD.checking) return;
|
|
1411
|
+
const oldVer = UPD.currentVer;
|
|
1412
|
+
UPD.applying = true;
|
|
1413
|
+
renderUpdState();
|
|
1414
|
+
try {
|
|
1415
|
+
const r = await api('/api/update/apply', { method: 'POST' });
|
|
1416
|
+
if (r.up_to_date) {
|
|
1417
|
+
UPD.last = { ok: true, up_to_date: true, current_version: r.version };
|
|
1418
|
+
} else {
|
|
1419
|
+
UPD.restarting = true;
|
|
1420
|
+
renderUpdState();
|
|
1421
|
+
pollAfterUpdate(oldVer);
|
|
1422
|
+
return; // restarting 状态保持到页面刷新
|
|
1423
|
+
}
|
|
1424
|
+
} catch (e) {
|
|
1425
|
+
UPD.last = { ok: false, error: e.message };
|
|
1426
|
+
}
|
|
1427
|
+
UPD.applying = false;
|
|
1428
|
+
renderUpdState();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function pollAfterUpdate(oldVer, tries = 0) {
|
|
1432
|
+
setTimeout(async () => {
|
|
1433
|
+
try {
|
|
1434
|
+
const v = await api('/api/version');
|
|
1435
|
+
if (v.version !== oldVer || tries >= 15) { location.reload(); return; }
|
|
1436
|
+
} catch (_) { /* 重启中,继续等 */ }
|
|
1437
|
+
if (tries >= 25) { location.reload(); return; } // 服务长时间无响应也强制刷新
|
|
1438
|
+
pollAfterUpdate(oldVer, tries + 1);
|
|
1439
|
+
}, 1200);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// 启动时静默获取版本号
|
|
1443
|
+
api('/api/version').then((v) => { UPD.currentVer = v.version; renderUpdState(); }).catch(() => {});
|
|
1444
|
+
|
|
1445
|
+
/* ================= 帮助面板 ================= */
|
|
1446
|
+
$('btn-help').addEventListener('click', () => {
|
|
1447
|
+
const p = $('help-panel');
|
|
1448
|
+
const opening = !p.classList.contains('show');
|
|
1449
|
+
if (opening) { $('info-card').style.display = 'none'; mountHelpPanel(); }
|
|
1450
|
+
p.classList.toggle('show');
|
|
1451
|
+
});
|
|
1452
|
+
$('help-close').addEventListener('click', () => {
|
|
1453
|
+
$('help-panel').classList.remove('show');
|
|
1454
|
+
$('info-card').style.display = '';
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
/* ================= 文件菜单(手机端:打开/保存/另存/网页版) ================= */
|
|
1458
|
+
$('m-file').addEventListener('click', (e) => {
|
|
1459
|
+
e.stopPropagation();
|
|
1460
|
+
$('file-menu').classList.toggle('show');
|
|
1461
|
+
});
|
|
1462
|
+
document.addEventListener('click', (e) => {
|
|
1463
|
+
if (!$('file-menu').contains(e.target) && e.target !== $('m-file')) $('file-menu').classList.remove('show');
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
function downloadUrl(url) {
|
|
1467
|
+
const a = document.createElement('a');
|
|
1468
|
+
a.href = url;
|
|
1469
|
+
a.style.display = 'none';
|
|
1470
|
+
document.body.appendChild(a);
|
|
1471
|
+
a.click();
|
|
1472
|
+
setTimeout(() => a.remove(), 800);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
$('fm-save').addEventListener('click', () => { downloadUrl('/api/export/db'); $('file-menu').classList.remove('show'); });
|
|
1476
|
+
$('fm-saveas').addEventListener('click', () => {
|
|
1477
|
+
const name = prompt('另存为图谱文件名', `kg-${new Date().toISOString().slice(0, 10)}.db`);
|
|
1478
|
+
if (name === null) return;
|
|
1479
|
+
const safe = encodeURIComponent(name.trim() || 'kg.db');
|
|
1480
|
+
downloadUrl('/api/export/db?name=' + safe);
|
|
1481
|
+
$('file-menu').classList.remove('show');
|
|
1482
|
+
});
|
|
1483
|
+
$('fm-savehtml').addEventListener('click', () => {
|
|
1484
|
+
const name = prompt('另存为图谱网页文件名', `kg-viewer-${new Date().toISOString().slice(0, 10)}.html`);
|
|
1485
|
+
if (name === null) return;
|
|
1486
|
+
const safe = encodeURIComponent(name.trim() || 'kg-viewer.html');
|
|
1487
|
+
downloadUrl('/api/export/html?name=' + safe);
|
|
1488
|
+
$('file-menu').classList.remove('show');
|
|
1489
|
+
toast('单文件查看器已开始下载:纯静态HTML,内嵌全部图谱数据与图片,发给他人用浏览器打开即可浏览');
|
|
1490
|
+
});
|
|
1491
|
+
$('fm-rdf').addEventListener('click', () => { window.open('/api/export/rdf', '_blank'); $('file-menu').classList.remove('show'); });
|
|
1492
|
+
$('fm-open').addEventListener('click', () => { $('file-menu').classList.remove('show'); $('db-file-input').click(); });
|
|
1493
|
+
$('db-file-input').addEventListener('change', async () => {
|
|
1494
|
+
const f = $('db-file-input').files[0];
|
|
1495
|
+
$('db-file-input').value = '';
|
|
1496
|
+
if (!f) return;
|
|
1497
|
+
if (!/\.(db|sqlite|sqlite3)$/i.test(f.name)) return toast('请选择 .db / .sqlite 图谱数据库文件', true);
|
|
1498
|
+
if (!confirm(`打开《${f.name}》将替换当前图谱。\n替换前会自动打保存点备份当前数据,可通过版本回溯找回。是否继续?`)) return;
|
|
1499
|
+
try {
|
|
1500
|
+
const b64 = await readAsB64(f);
|
|
1501
|
+
const r = await api('/api/graph/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, content_b64: b64 }) });
|
|
1502
|
+
let msg = `已打开图谱《${f.name}》:实体 ${r.counts.entities} / 关系 ${r.counts.relations}(原数据备份于 ${r.backup_short || '最新保存点'})`;
|
|
1503
|
+
if (r.pruned_images > 0) msg += `;${r.pruned_images} 张图片文件未随库迁移,已清理对应绑定`;
|
|
1504
|
+
toast(msg);
|
|
1505
|
+
exitEgo();
|
|
1506
|
+
state.selected = null;
|
|
1507
|
+
await refreshAll();
|
|
1508
|
+
loadHistory();
|
|
1509
|
+
} catch (e) { toast('打开图谱失败: ' + e.message, true); }
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
/* ================= 手机端头部紧凑标签 ================= */
|
|
1513
|
+
const HEADER_LABELS = [
|
|
1514
|
+
['btn-savepoint', '打保存点', '存点'],
|
|
1515
|
+
['btn-relayout', '重新布局', '布局'],
|
|
1516
|
+
['btn-resetview', '重置视角', '视角'],
|
|
1517
|
+
['btn-style', '视图设置', '设置'],
|
|
1518
|
+
['btn-help', '帮助', '帮助'],
|
|
1519
|
+
];
|
|
1520
|
+
function compactHeader(mobile) {
|
|
1521
|
+
for (const [id, , short] of HEADER_LABELS) {
|
|
1522
|
+
const b = $(id);
|
|
1523
|
+
if (!b) continue;
|
|
1524
|
+
if (mobile) {
|
|
1525
|
+
if (!b.dataset.full) b.dataset.full = b.textContent;
|
|
1526
|
+
b.textContent = short;
|
|
1527
|
+
} else if (b.dataset.full) {
|
|
1528
|
+
b.textContent = b.dataset.full;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
compactHeader(isMobile());
|
|
1533
|
+
window.addEventListener('resize', () => compactHeader(window.matchMedia('(max-width: 768px)').matches));
|
|
1534
|
+
|
|
1535
|
+
/* ================= 数据刷新与同步 ================= */
|
|
1536
|
+
async function refreshAll(rebuild = true) {
|
|
1537
|
+
const [graph, meta] = await Promise.all([api('/api/graph'), api('/api/meta')]);
|
|
1538
|
+
state.entities = graph.entities;
|
|
1539
|
+
state.relations = graph.relations;
|
|
1540
|
+
state.version = meta.version;
|
|
1541
|
+
state.imageCounts = new Map((graph.image_counts || []).map((x) => [Number(x.entity_id), x.count]));
|
|
1542
|
+
// 推理开关开启时同步刷新推理缓存,保证叠加边与新数据一致
|
|
1543
|
+
if (state.showInferred) {
|
|
1544
|
+
try { state.inferredData = await api('/api/inference'); } catch (_) { /* 保留旧缓存 */ }
|
|
1545
|
+
}
|
|
1546
|
+
// 中心实体被删除或回溯消失时自动退出中心模式
|
|
1547
|
+
if (state.ego && !state.entities.find((e) => e.id === state.ego.centerId)) {
|
|
1548
|
+
state.ego = null;
|
|
1549
|
+
updateEgoBar();
|
|
1550
|
+
}
|
|
1551
|
+
$('stat-badge').textContent = `实体 ${meta.counts.entities} / 关系 ${meta.counts.relations} / 日志 ${meta.counts.logs}`;
|
|
1552
|
+
const badge = $('agent-badge');
|
|
1553
|
+
if (meta.agent_available) { badge.textContent = 'OpenCode 已就绪'; badge.className = 'badge ok'; }
|
|
1554
|
+
else { badge.textContent = 'OpenCode 未安装'; badge.className = 'badge off'; }
|
|
1555
|
+
refreshEntityOptions();
|
|
1556
|
+
rebuildGraph();
|
|
1557
|
+
renderEntityList();
|
|
1558
|
+
renderRelationList();
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function initPolling() {
|
|
1562
|
+
setInterval(async () => {
|
|
1563
|
+
try {
|
|
1564
|
+
const meta = await api('/api/meta');
|
|
1565
|
+
if (meta.version !== state.version) await refreshAll();
|
|
1566
|
+
} catch (_) { /* 本地服务短暂不可达时静默重试 */ }
|
|
1567
|
+
}, 3000);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/* ================= 检索页签 ================= */
|
|
1571
|
+
// 智能提问:LLM编译检索计划→只读执行→关联发现→综述
|
|
1572
|
+
$('a-submit').addEventListener('click', askSubmit);
|
|
1573
|
+
$('a-question').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); askSubmit(); } });
|
|
1574
|
+
|
|
1575
|
+
async function askSubmit() {
|
|
1576
|
+
const q = $('a-question').value.trim();
|
|
1577
|
+
if (!q) return toast('请输入问题', true);
|
|
1578
|
+
const btn = $('a-submit');
|
|
1579
|
+
btn.disabled = true;
|
|
1580
|
+
$('a-status').textContent = '智能检索中…(编译与执行)';
|
|
1581
|
+
try {
|
|
1582
|
+
const r = await api('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: q }) });
|
|
1583
|
+
renderAskResult(r);
|
|
1584
|
+
$('a-status').textContent = `完成(编译${(r.timings.compile_ms / 1000).toFixed(1)}s / 执行${r.timings.execute_ms}ms)`;
|
|
1585
|
+
} catch (e) {
|
|
1586
|
+
toast(e.message, true);
|
|
1587
|
+
$('a-status').textContent = '';
|
|
1588
|
+
} finally {
|
|
1589
|
+
btn.disabled = false;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function renderAskResult(r) {
|
|
1594
|
+
const box = $('a-result');
|
|
1595
|
+
const chips = r.steps.map((s) => {
|
|
1596
|
+
const icon = s.status === 'ok' ? '✔' : s.status === 'timeout' ? '⏱' : '✘';
|
|
1597
|
+
const label = s.tool + (s.note ? `(${s.note})` : '') + (s.error ? `(${s.error})` : '');
|
|
1598
|
+
return `<span class="ask-chip ${s.status}">${icon} ${escapeHtml(label)}</span>`;
|
|
1599
|
+
}).join('');
|
|
1600
|
+
if (r.degraded) chips += '<span class="ask-chip degraded">智能编译失败,已降级关键词</span>';
|
|
1601
|
+
|
|
1602
|
+
const ents = r.entities.map((e) => `
|
|
1603
|
+
<div class="list-item" style="cursor:pointer" onclick="focusEntity(${e.id})">
|
|
1604
|
+
<b>${escapeHtml(e.name)}</b>
|
|
1605
|
+
<span class="tag" style="color:${ENTITY_STYLE[e.category].css};border-color:${ENTITY_STYLE[e.category].css}55">${e.category}</span>
|
|
1606
|
+
</div>`).join('') || '<div class="kv">无匹配实体</div>';
|
|
1607
|
+
|
|
1608
|
+
let cn = '';
|
|
1609
|
+
if (r.co_neighbors.length) {
|
|
1610
|
+
const nameOf = (id) => { const e = r.entities.find((x) => x.id === id); return e ? escapeHtml(e.name) : '#' + id; };
|
|
1611
|
+
cn += `<div class="ask-sec">关联发现</div>` + r.co_neighbors.map((p) =>
|
|
1612
|
+
`<div class="kv">↔ ${nameOf(p.a)} 与 ${nameOf(p.b)}:${p.shared.length} 个公共邻居</div>`).join('');
|
|
1613
|
+
}
|
|
1614
|
+
if (r.bridges.length) {
|
|
1615
|
+
cn += r.bridges.map((b) => `<div class="kv">⬡ 桥接节点 <span style="color:#7fd1ff;cursor:pointer" onclick="focusEntity(${b.id})">${escapeHtml(b.name)}</span>(连接结果内 ${b.links} 个实体)</div>`).join('');
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
let synth = '';
|
|
1619
|
+
if (r.synthesis) synth = `<div class="ask-synth">${renderSynthesis(r.synthesis, r.entities)}</div>`;
|
|
1620
|
+
else if (r.synth_error) synth = `<div class="kv" style="color:#e0a768">综述生成失败:${escapeHtml(r.synth_error)}</div>`;
|
|
1621
|
+
|
|
1622
|
+
box.innerHTML = `<div class="ask-steps">${chips}</div>${synth}<div class="ask-sec">实体(${r.entities.length})</div>${ents}${cn}`;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 综述文本中的「名称#id」渲染为可点击引用
|
|
1626
|
+
function renderSynthesis(text, entities) {
|
|
1627
|
+
const ids = new Set(entities.map((e) => e.id));
|
|
1628
|
+
return escapeHtml(text).replace(/「([^「」]+)#(\d+)」/g, (m, name, id) => {
|
|
1629
|
+
if (!ids.has(Number(id))) return m;
|
|
1630
|
+
return `<span class="syn-ref" onclick="focusEntity(${id})">${name}</span>`;
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
async function loadSearchStatus() {
|
|
1635
|
+
try {
|
|
1636
|
+
const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
|
|
1637
|
+
$('s-status').textContent = `已索引 ${st.indexed}/${st.total_entities}`;
|
|
1638
|
+
if (!$('s-model').value) $('s-model').value = cfg.model;
|
|
1639
|
+
$('s-key').placeholder = cfg.api_key === '已配置' ? '已配置(输入新值可更换)' : '仅存本地 data/settings.json';
|
|
1640
|
+
} catch (_) { /* 服务未就绪时静默 */ }
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function renderSearchResults(r) {
|
|
1644
|
+
$('s-mode').textContent = r.mode === 'hybrid' ? '语义+关键词融合' : '仅关键词(未配置key或未建向量)';
|
|
1645
|
+
if (!r.results.length) { $('s-results').innerHTML = '<div class="kv" style="margin-top:8px">无匹配结果</div>'; return; }
|
|
1646
|
+
$('s-results').innerHTML = r.results.map((x, i) => `
|
|
1647
|
+
<div class="list-item" style="cursor:pointer" onclick="focusEntity(${x.entity.id})">
|
|
1648
|
+
<b>${i + 1}. ${escapeHtml(x.entity.name)}</b>
|
|
1649
|
+
<span class="tag" style="color:${ENTITY_STYLE[x.entity.category].css};border-color:${ENTITY_STYLE[x.entity.category].css}55">${x.entity.category}</span>
|
|
1650
|
+
<div class="kv">语义 ${x.semantic_score ?? '—'} 关键词 ${x.keyword_score ?? '—'} RRF ${x.rrf_score} 关联 ${x.hit_relations} 条</div>
|
|
1651
|
+
</div>`).join('');
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// 智能提问设置:启动时读综述开关,变更即保存
|
|
1655
|
+
api('/api/ask/settings').then((s) => { $('a-synth').checked = !!s.synthesis; }).catch(() => {});
|
|
1656
|
+
$('a-synth').addEventListener('change', () => {
|
|
1657
|
+
api('/api/ask/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ synthesis: $('a-synth').checked }) }).catch(() => {});
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
$('s-save').addEventListener('click', async () => {
|
|
1661
|
+
const patch = { model: $('s-model').value.trim() || 'BAAI/bge-m3' };
|
|
1662
|
+
const key = $('s-key').value.trim();
|
|
1663
|
+
if (key) patch.api_key = key;
|
|
1664
|
+
try {
|
|
1665
|
+
await api('/api/embeddings/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
1666
|
+
$('s-key').value = '';
|
|
1667
|
+
loadSearchStatus();
|
|
1668
|
+
} catch (e) { $('s-status').textContent = e.message; }
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
$('s-build').addEventListener('click', async () => {
|
|
1672
|
+
const btn = $('s-build');
|
|
1673
|
+
btn.disabled = true;
|
|
1674
|
+
$('s-status').textContent = '构建中…';
|
|
1675
|
+
try {
|
|
1676
|
+
const r = await api('/api/embeddings/build', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
1677
|
+
$('s-status').textContent = `已索引 ${r.indexed}/${r.total}`;
|
|
1678
|
+
} catch (e) { $('s-status').textContent = e.message; }
|
|
1679
|
+
btn.disabled = false;
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
async function runSearch() {
|
|
1683
|
+
const q = $('s-query').value.trim();
|
|
1684
|
+
if (!q) return;
|
|
1685
|
+
$('s-mode').textContent = '检索中…';
|
|
1686
|
+
try { renderSearchResults(await api('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, top_k: 10 }) })); }
|
|
1687
|
+
catch (e) { $('s-mode').textContent = e.message; }
|
|
1688
|
+
}
|
|
1689
|
+
$('s-run').addEventListener('click', runSearch);
|
|
1690
|
+
$('s-query').addEventListener('keydown', (e) => { if (e.key === 'Enter') runSearch(); });
|
|
1691
|
+
|
|
1692
|
+
$('c-run').addEventListener('click', async () => {
|
|
1693
|
+
const q = $('c-query').value.trim();
|
|
1694
|
+
if (!q) return;
|
|
1695
|
+
try {
|
|
1696
|
+
const r = await api('/api/cypher', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q }) });
|
|
1697
|
+
$('c-hint').textContent = `${r.rows.length} 行`;
|
|
1698
|
+
$('c-results').innerHTML = '<pre style="font-size:11px;color:#cdd9e5;white-space:pre-wrap">' + escapeHtml(JSON.stringify(r.rows, null, 1)) + '</pre>';
|
|
1699
|
+
} catch (e) { $('c-hint').textContent = e.message; }
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
/* ================= 事件绑定与启动 ================= */
|
|
1703
|
+
initTabs();
|
|
1704
|
+
$('e-submit').addEventListener('click', submitEntity);
|
|
1705
|
+
$('e-cancel').addEventListener('click', cancelEditEntity);
|
|
1706
|
+
$('r-submit').addEventListener('click', submitRelation);
|
|
1707
|
+
$('agent-send').addEventListener('click', sendAgent);
|
|
1708
|
+
$('agent-input').addEventListener('keydown', (e) => {
|
|
1709
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendAgent(); }
|
|
1710
|
+
});
|
|
1711
|
+
$('btn-savepoint').addEventListener('click', () => doSavepoint(''));
|
|
1712
|
+
$('v-save').addEventListener('click', () => doSavepoint($('v-message').value));
|
|
1713
|
+
$('btn-export').addEventListener('click', () => window.open('/api/export/rdf', '_blank'));
|
|
1714
|
+
$('btn-inference').addEventListener('click', async () => {
|
|
1715
|
+
state.showInferred = !state.showInferred;
|
|
1716
|
+
$('btn-inference').classList.toggle('active', state.showInferred);
|
|
1717
|
+
localStorage.setItem('kg_inference_v1', state.showInferred ? '1' : '0');
|
|
1718
|
+
if (state.showInferred) {
|
|
1719
|
+
try { state.inferredData = await api('/api/inference'); }
|
|
1720
|
+
catch (_) { state.showInferred = false; $('btn-inference').classList.remove('active'); localStorage.removeItem('kg_inference_v1'); return; }
|
|
1721
|
+
}
|
|
1722
|
+
rebuildGraph();
|
|
1723
|
+
});
|
|
1724
|
+
$('btn-relayout').addEventListener('click', () => {
|
|
1725
|
+
for (const nd of simNodes) {
|
|
1726
|
+
nd.pos.set((Math.random() - 0.5) * 180, (Math.random() - 0.5) * 120, (Math.random() - 0.5) * 180);
|
|
1727
|
+
nd.vel.set(0, 0, 0);
|
|
1728
|
+
}
|
|
1729
|
+
simBudget = 420;
|
|
1730
|
+
settleCount = 0;
|
|
1731
|
+
});
|
|
1732
|
+
$('btn-resetview').addEventListener('click', () => {
|
|
1733
|
+
camera.position.set(0, 90, 260);
|
|
1734
|
+
controls.target.set(0, 0, 0);
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
(async function boot() {
|
|
1738
|
+
resize();
|
|
1739
|
+
try {
|
|
1740
|
+
const meta = await api('/api/meta');
|
|
1741
|
+
fillCategorySelects(meta);
|
|
1742
|
+
await refreshAll();
|
|
1743
|
+
} catch (e) {
|
|
1744
|
+
toast('后端连接失败: ' + e.message, true);
|
|
1745
|
+
}
|
|
1746
|
+
initPolling();
|
|
1747
|
+
loadSearchStatus();
|
|
1748
|
+
// 推理开关持久化:上次会话开启时启动即恢复叠加
|
|
1749
|
+
if (localStorage.getItem('kg_inference_v1') === '1') {
|
|
1750
|
+
$('btn-inference').classList.add('active');
|
|
1751
|
+
try {
|
|
1752
|
+
state.inferredData = await api('/api/inference');
|
|
1753
|
+
state.showInferred = true;
|
|
1754
|
+
rebuildGraph();
|
|
1755
|
+
} catch (_) {
|
|
1756
|
+
state.showInferred = false;
|
|
1757
|
+
$('btn-inference').classList.remove('active');
|
|
1758
|
+
localStorage.removeItem('kg_inference_v1');
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
})();
|