Files
TrulyMEM-TrueHumanMEM-local/ui/static/graph.html

2106 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>记忆星图 - TrulyMEM</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background: #0a0a1a;
color: #ffffff;
overflow: hidden;
width: 100vw;
height: 100vh;
}
#canvas-container {
width: 100%;
height: 100%;
position: relative;
}
canvas {
display: block;
}
#stats {
position: absolute;
top: 20px;
left: 20px;
background: rgba(10, 10, 26, 0.8);
padding: 15px 20px;
border-radius: 8px;
border: 1px solid rgba(100, 100, 255, 0.3);
font-size: 14px;
z-index: 100;
backdrop-filter: blur(10px);
}
#stats h3 {
margin-bottom: 8px;
color: #4488ff;
font-size: 16px;
}
#stats p {
margin: 4px 0;
color: #aaaacc;
}
#stats span {
color: #ffffff;
font-weight: bold;
}
/* 开关切换 */
.toggle-row {
margin-top: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.toggle-row .label-text {
color: #8888aa;
font-size: 12px;
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-track {
position: absolute;
inset: 0;
background: rgba(60,60,80,0.8);
border-radius: 10px;
transition: all 0.3s;
border: 1px solid rgba(100,100,255,0.2);
}
.toggle-track.on {
background: rgba(68,136,255,0.5);
border-color: #4488ff;
}
.toggle-knob {
position: absolute;
width: 16px;
height: 16px;
left: 2px;
top: 2px;
background: #6666aa;
border-radius: 50%;
transition: all 0.3s;
}
.toggle-knob.on {
left: 18px;
background: #4488ff;
}
.toggle-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid rgba(100,100,255,0.15);
background: transparent;
color: #8888aa;
font-size: 12px;
font-family: 'Courier New', monospace;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
background: rgba(68,136,255,0.15);
color: #fff;
}
.toggle-btn.on {
background: rgba(68,136,255,0.3);
color: #4488ff;
border-color: #4488ff;
}
#node-info {
position: absolute;
top: 20px;
right: 20px;
background: rgba(10, 10, 26, 0.9);
padding: 15px 20px;
border-radius: 8px;
border: 1px solid rgba(100, 100, 255, 0.3);
font-size: 14px;
z-index: 100;
display: none;
backdrop-filter: blur(10px);
max-width: 300px;
}
#node-info h3 {
color: #44ff88;
margin-bottom: 8px;
font-size: 16px;
}
#node-info p {
margin: 4px 0;
color: #aaaacc;
}
#node-info .label {
color: #8888aa;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
color: #4488ff;
z-index: 200;
}
#nav {
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
z-index: 100;
}
.nav-btn {
background: rgba(10, 10, 26, 0.8);
border: 1px solid rgba(100, 100, 255, 0.3);
color: #aaaacc;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: all 0.3s;
backdrop-filter: blur(10px);
text-decoration: none;
display: inline-block;
}
.nav-btn:hover {
background: rgba(68, 136, 255, 0.2);
color: #ffffff;
border-color: rgba(68, 136, 255, 0.6);
}
.nav-btn.active {
background: rgba(68, 136, 255, 0.3);
color: #ffffff;
border-color: #4488ff;
}
/* 主容器 - 侧边栏布局 */
#app-container {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
#canvas-container {
flex: 1;
height: 100%;
position: relative;
min-width: 0;
overflow: hidden;
}
#chat-panel {
width: 380px;
min-width: 320px;
height: 100%;
background: rgba(10, 10, 26, 0.92);
border-left: 1px solid rgba(100, 100, 255, 0.2);
display: flex;
flex-direction: column;
z-index: 50;
backdrop-filter: blur(12px);
transition: width 0.3s, opacity 0.3s;
}
#chat-panel.collapsed {
width: 0;
min-width: 0;
overflow: hidden;
opacity: 0;
border: none;
}
#chat-panel.collapsed + #toggle-chat {
right: 12px;
}
#toggle-chat {
position: absolute;
top: 20px;
right: 392px;
background: rgba(10, 10, 26, 0.8);
border: 1px solid rgba(100, 100, 255, 0.3);
color: #aaa;
font-size: 18px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
z-index: 200;
transition: right 0.3s;
}
#toggle-chat:hover {
background: rgba(68, 136, 255, 0.3);
color: #fff;
}
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(100, 100, 255, 0.15);
font-size: 15px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chat-close {
background: none;
border: none;
color: #888;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.chat-close:hover { color: #fff; background: rgba(255,0,0,0.2); }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-msg {
max-width: 85%;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: rgba(68, 136, 255, 0.25);
border: 1px solid rgba(68, 136, 255, 0.3);
color: #eef;
}
.chat-msg.assistant {
align-self: flex-start;
background: rgba(0, 255, 136, 0.08);
border: 1px solid rgba(0, 255, 136, 0.15);
color: #dde;
}
.chat-msg-time {
font-size: 10px;
color: #667;
margin-top: 4px;
text-align: right;
}
.chat-input-area {
padding: 10px 12px;
padding-bottom: max(10px, calc(10px + env(safe-area-inset-bottom)));
border-top: 1px solid rgba(100, 100, 255, 0.15);
display: flex;
gap: 6px;
flex-shrink: 0;
}
.chat-input-area input {
flex: 1;
background: rgba(20, 20, 40, 0.9);
border: 1px solid rgba(100, 100, 255, 0.2);
color: #eee;
padding: 10px 14px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
outline: none;
}
.chat-input-area input:focus {
border-color: rgba(68, 136, 255, 0.5);
}
.chat-input-area button {
background: rgba(68, 136, 255, 0.3);
border: 1px solid rgba(68, 136, 255, 0.4);
color: #eee;
padding: 10px 18px;
border-radius: 6px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.chat-input-area button:hover {
background: rgba(68, 136, 255, 0.5);
}
.attach-btn {
background: rgba(68, 136, 255, 0.2);
border: 1px solid rgba(68, 136, 255, 0.3);
color: #aaf;
width: 40px;
height: 40px;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.attach-btn:hover {
background: rgba(68, 136, 255, 0.4);
}
.chat-files-container {
display: flex;
flex-wrap: wrap;
gap: 3px;
padding: 2px 12px 6px;
min-height: 0;
}
.chat-file-chip {
display: flex;
align-items: center;
gap: 4px;
background: rgba(40, 40, 80, 0.7);
border: 1px solid rgba(100, 100, 255, 0.2);
border-radius: 3px;
padding: 2px 6px;
font-size: 11px;
color: #dde;
max-width: 160px;
cursor: default;
}
.chat-file-chip .file-icon {
color: #7af;
flex-shrink: 0;
font-size: 10px;
}
.chat-file-chip .file-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
font-size: 11px;
}
.chat-file-chip .file-size {
color: #667;
font-size: 9px;
flex-shrink: 0;
}
.chat-file-chip .file-remove {
color: #f66;
cursor: pointer;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
.chat-file-chip .file-remove:hover {
color: #f33;
}
.chat-messages.drag-over {
background: rgba(68, 136, 255, 0.12);
border: 2px dashed rgba(68, 136, 255, 0.5);
}
.chat-status {
padding: 8px 16px;
border-top: 1px solid rgba(100, 100, 255, 0.1);
font-size: 11px;
color: #668;
flex-shrink: 0;
}
.chat-status.processing { color: #ffd700; }
.chat-status.error { color: #e94560; }
.chat-welcome {
text-align: center;
color: #667;
padding: 40px 20px;
font-size: 13px;
line-height: 1.6;
}
/* 窄屏自适应 */
@media (max-width: 768px) {
#app-container { flex-direction: column; }
#canvas-container { height: 55%; }
#chat-panel {
width: 100% !important;
min-width: 0;
height: 45%;
border-left: none;
border-top: 1px solid rgba(100,100,255,0.2);
}
#chat-panel.collapsed { height: 0; }
#toggle-chat {
right: 12px !important;
top: auto;
bottom: 48%;
}
}
.chat-msg .markdown-content p { margin: 4px 0; }
.chat-msg .markdown-content code { background: rgba(255,255,255,0.1); padding: 1px 4px; border-radius: 3px; font-size: 12px; }
.chat-msg .markdown-content pre { background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px; overflow-x: auto; margin: 6px 0; }
.chat-msg .markdown-content pre code { background: none; padding: 0; }
.chat-msg .markdown-content ul, .chat-msg .markdown-content ol { padding-left: 20px; margin: 4px 0; }
.chat-msg .markdown-content h1, .chat-msg .markdown-content h2, .chat-msg .markdown-content h3, .chat-msg .markdown-content h4 { margin: 8px 0 4px; color: #aaccff; }
.chat-msg .markdown-content blockquote { border-left: 3px solid #4488ff; padding-left: 10px; margin: 4px 0; color: #8899bb; }
.chat-msg .markdown-content a { color: #4488ff; text-decoration: underline; }
.chat-msg .markdown-content strong { color: #fff; }
.chat-msg .markdown-content table { border-collapse: collapse; margin: 6px 0; width: 100%; }
.chat-msg .markdown-content th, .chat-msg .markdown-content td { border: 1px solid rgba(100,100,255,0.3); padding: 4px 8px; text-align: left; }
.chat-msg .markdown-content th { background: rgba(68,136,255,0.2); }
.chat-msg .markdown-content hr { border: none; border-top: 1px solid rgba(100,100,255,0.2); margin: 8px 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js"></script>
</head>
<body>
<div id="app-container">
<div id="canvas-container">
<div id="loading">正在加载星图数据...</div>
<div id="debug-info" style="position:absolute;bottom:10px;left:10px;background:rgba(0,0,0,0.7);color:#0f0;padding:4px 8px;font-size:11px;z-index:300;border-radius:4px;"></div>
<div id="stats">
<h3><i class="fas fa-star"></i> 记忆星图</h3>
<p>节点: <span id="node-count">0</span></p>
<p>边: <span id="edge-count">0</span></p>
<p>状态: <span id="status">初始化中...</span></p>
<div class="toggle-row">
<span style="display:flex;align-items:center;gap:6px">
<span class="label-text">自动视角</span>
<label class="toggle-switch" id="autoViewLabel">
<input type="checkbox" id="autoViewToggle" checked style="display:none">
<span class="toggle-track on" id="autoTrack"></span>
<span class="toggle-knob on" id="autoKnob"></span>
</label>
</span>
<span style="display:flex;align-items:center;gap:6px">
<span class="label-text">活动记录</span>
<label class="toggle-switch" id="activityLabel">
<input type="checkbox" id="activityCheck" checked style="display:none">
<span class="toggle-track on" id="activityTrack"></span>
<span class="toggle-knob on" id="activityKnob"></span>
</label>
</span>
</div>
</div>
<div id="node-info">
<h3 id="info-name"></h3>
<p><span class="label">类型:</span> <span id="info-type"></span></p>
<p><span class="label">提及次数:</span> <span id="info-mentions"></span></p>
<p><span class="label">连接数:</span> <span id="info-links"></span></p>
</div>
<div id="nav">
<a href="/graph.html" class="nav-btn active"><i class="fas fa-star"></i> 星图</a>
<a href="/settings" class="nav-btn"><i class="fas fa-cog"></i> 设置</a>
</div>
</div>
<!-- 聊天面板 -->
<div id="chat-panel">
<div class="chat-header">
<span><i class="fas fa-comment-dots"></i> 与 TrulyMEM 对话</span>
<button class="chat-close" id="chatCloseBtn" title="关闭聊天"></button>
</div>
<div class="chat-messages" id="chatMessages">
<div class="chat-welcome">你好!我是 TrulyMEM你的图记忆助手~</div>
</div>
<div class="chat-input-area">
<input type="text" id="chatInput" placeholder="输入消息..." autocomplete="off">
<button class="attach-btn" id="attachBtn" title="添加文件"><i class="fas fa-paperclip"></i></button>
<button id="chatSendBtn">发送</button>
</div>
<input type="file" id="fileInput" multiple style="display:none">
<div class="chat-files-container" id="chatFilesContainer"></div>
<div class="chat-status" id="chatStatus"><span class="status-dot" style="color:#22c55e"></span> 已连接</div>
</div>
<button id="toggle-chat"><i class="fas fa-thumbtack"></i></button>
</div> <!-- /app-container -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
// Check authentication
fetch('/api/check-auth').then(r=>r.json()).then(d=>{if(!d.authenticated)window.location.href='/login'}).catch(()=>window.location.href='/login')
function renderMarkdown(text) {
if (!text) return '';
try {
const html = marked.parse(text);
return DOMPurify.sanitize(html);
} catch(e) {
return text;
}
}
// 全局变量
let scene, camera, renderer, controls;
let nodes = [], edges = [];
let nodeMeshes = [], edgeLines = [];
let starField, nebulaParticles;
let raycaster, mouse;
let hoveredNode = null, selectedNode = null;
let animationId;
let autoViewEnabled = true;
let highlightPulse = 0;
let lastHighlightCheck = 0;
let currentHighlightIds = [];
let currentHighlightEdgeIds = new Set(); // 存储高亮的边ID
let edgeParticles = {}; // 存储边的流动光点 {edgeId: {mesh, progress, edge}}
// 颜色映射
const typeColors = {
'person': 0x4488ff,
'task': 0xff8844,
'ai': 0xaa44ff,
'concept': 0x44ff88,
'object': 0xff4444
};
const defaultColor = 0xcccccc;
// 边颜色映射
const edgeColors = {
'喜欢': 0xff6b6b,
'学习': 0x4ecdc4,
'属于': 0x45b7d1,
'相关': 0x96ceb4,
'使用': 0xfeca57,
'创建': 0xff9ff3
};
const defaultEdgeColor = 0x444466;
// 初始化
// Debug 信息(显示渲染器尺寸)
function showDebugInfo() {
const canvas = renderer ? renderer.domElement : null;
const info = document.getElementById('debug-info');
if (info && canvas) {
info.textContent = `Canvas: ${canvas.width}x${canvas.height} | Container: ${document.getElementById('canvas-container').clientWidth}x${document.getElementById('canvas-container').clientHeight}`;
}
setTimeout(showDebugInfo, 2000);
}
function init() {
// 创建场景
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
// 创建相机
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 30, 60);
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// 获取容器尺寸,初始化渲染器
const initContainer = document.getElementById('canvas-container');
const initRect = initContainer.getBoundingClientRect();
const initW = initRect.width || window.innerWidth;
const initH = initRect.height || window.innerHeight;
renderer.setSize(Math.max(initW, 100), Math.max(initH, 100));
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x0a0a1a, 1);
document.getElementById('canvas-container').appendChild(renderer.domElement);
// 创建控制器
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.5;
controls.zoomSpeed = 0.8;
// 光照
const ambientLight = new THREE.AmbientLight(0x444466, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
scene.add(directionalLight);
// 射线检测
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
// 创建星空背景
createStarField();
createNebula();
// 事件监听
window.addEventListener('resize', onWindowResize);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('click', onMouseClick);
// 加载图数据
loadGraphData();
// 开始动画循环
animate();
}
// 创建星空背景
// 自动视角开关事件
var autoViewToggle = document.getElementById("autoViewToggle");
var autoTrack = document.getElementById("autoTrack");
var autoKnob = document.getElementById("autoKnob");
function updateAutoViewUI() {
if (autoViewToggle.checked) {
autoTrack.className = "toggle-track on";
autoKnob.className = "toggle-knob on";
} else {
autoTrack.className = "toggle-track";
autoKnob.className = "toggle-knob";
}
}
var activityCheck = document.getElementById("activityCheck");
var activityTrack = document.getElementById("activityTrack");
var activityKnob = document.getElementById("activityKnob");
function updateActivityUI() {
if (activityCheck.checked) {
activityTrack.className = "toggle-track on";
activityKnob.className = "toggle-knob on";
} else {
activityTrack.className = "toggle-track";
activityKnob.className = "toggle-knob";
}
}
activityCheck.addEventListener("change", function() {
updateActivityUI();
});
document.getElementById("activityLabel").addEventListener("click", function(e) {
activityCheck.checked = !activityCheck.checked;
updateActivityUI();
});
autoViewToggle.addEventListener("change", function() {
autoViewEnabled = this.checked;
updateAutoViewUI();
});
document.getElementById("autoViewLabel").addEventListener("click", function(e) {
autoViewToggle.checked = !autoViewToggle.checked;
autoViewToggle.dispatchEvent(new Event('change'));
});
function createStarField() {
const starCount = 3000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const sizes = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
const i3 = i * 3;
// 随机位置(球形分布)
const radius = 400 + Math.random() * 600;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
positions[i3 + 2] = radius * Math.cos(phi);
// 随机颜色(偏冷色调)
const colorChoice = Math.random();
if (colorChoice < 0.7) {
colors[i3] = 0.8 + Math.random() * 0.2;
colors[i3 + 1] = 0.8 + Math.random() * 0.2;
colors[i3 + 2] = 1.0;
} else {
colors[i3] = 1.0;
colors[i3 + 1] = 0.9 + Math.random() * 0.1;
colors[i3 + 2] = 0.8 + Math.random() * 0.2;
}
sizes[i] = 0.5 + Math.random() * 2;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const material = new THREE.PointsMaterial({
size: 1.5,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true
});
starField = new THREE.Points(geometry, material);
scene.add(starField);
}
// 创建星云效果
function createNebula() {
const nebulaCount = 500;
const positions = new Float32Array(nebulaCount * 3);
const colors = new Float32Array(nebulaCount * 3);
for (let i = 0; i < nebulaCount; i++) {
const i3 = i * 3;
// 随机位置
positions[i3] = (Math.random() - 0.5) * 800;
positions[i3 + 1] = (Math.random() - 0.5) * 800;
positions[i3 + 2] = (Math.random() - 0.5) * 800;
// 星云颜色(紫色、蓝色、粉色)
const colorChoice = Math.random();
if (colorChoice < 0.33) {
colors[i3] = 0.5 + Math.random() * 0.3;
colors[i3 + 1] = 0.2 + Math.random() * 0.2;
colors[i3 + 2] = 0.7 + Math.random() * 0.3;
} else if (colorChoice < 0.66) {
colors[i3] = 0.2 + Math.random() * 0.2;
colors[i3 + 1] = 0.3 + Math.random() * 0.3;
colors[i3 + 2] = 0.8 + Math.random() * 0.2;
} else {
colors[i3] = 0.7 + Math.random() * 0.3;
colors[i3 + 1] = 0.2 + Math.random() * 0.2;
colors[i3 + 2] = 0.5 + Math.random() * 0.3;
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 8,
vertexColors: true,
transparent: true,
opacity: 0.15,
sizeAttenuation: true,
blending: THREE.AdditiveBlending
});
nebulaParticles = new THREE.Points(geometry, material);
scene.add(nebulaParticles);
}
// 加载图数据
async function loadGraphData() {
try {
document.getElementById('status').textContent = '加载中...';
const response = await fetch('/api/graph');
const data = await response.json();
if (data.success) {
nodes = data.nodes;
edges = data.edges;
document.getElementById('node-count').textContent = nodes.length;
document.getElementById('edge-count').textContent = edges.length;
document.getElementById('status').textContent = '就绪';
// 创建3D可视化
createGraphVisualization();
document.getElementById('loading').style.display = 'none';
} else {
document.getElementById('status').textContent = '加载失败';
document.getElementById('loading').textContent = '加载失败: ' + data.error;
}
} catch (error) {
document.getElementById('status').textContent = '错误';
document.getElementById('loading').textContent = '加载错误: ' + error.message;
}
}
// 创建图可视化
function createGraphVisualization() {
// 清除旧的对象(包括边粒子)
nodeMeshes.forEach(mesh => scene.remove(mesh));
edgeLines.forEach(line => scene.remove(line));
Object.keys(edgeParticles).forEach(key => {
scene.remove(edgeParticles[key].mesh);
});
nodeMeshes = [];
edgeLines = [];
edgeParticles = {};
if (nodes.length === 0) return;
// 计算节点度数
const nodeDegrees = {};
nodes.forEach(n => nodeDegrees[n.id] = 0);
edges.forEach(e => {
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
});
// 按度排序
const sortedNodes = [...nodes].sort((a, b) => (nodeDegrees[b.id] || 0) - (nodeDegrees[a.id] || 0));
// 计算布局位置(分层布局)
const positions = {};
const nodeMap = {};
// 第一层:度最高的节点放外层
const maxDegree = Math.max(...Object.values(nodeDegrees));
const layers = {
outer: [], // 度 > 70% 最大值
middle: [], // 度在 30%-70%
inner: [] // 度 < 30%
};
sortedNodes.forEach(node => {
const degree = nodeDegrees[node.id] || 0;
const ratio = maxDegree > 0 ? degree / maxDegree : 0;
if (ratio > 0.7) {
layers.outer.push(node);
} else if (ratio > 0.3) {
layers.middle.push(node);
} else {
layers.inner.push(node);
}
nodeMap[node.id] = node;
});
// 分配位置
let currentAngle = 0;
// 外层(大半径)
const outerRadius = 50;
layers.outer.forEach((node, i) => {
const angle = (i / layers.outer.length) * Math.PI * 2;
const x = outerRadius * Math.cos(angle);
const z = outerRadius * Math.sin(angle);
const y = (Math.random() - 0.5) * 20;
positions[node.id] = { x, y, z };
});
// 中层
const middleRadius = 30;
layers.middle.forEach((node, i) => {
const angle = (i / layers.middle.length) * Math.PI * 2 + currentAngle;
const x = middleRadius * Math.cos(angle);
const z = middleRadius * Math.sin(angle);
const y = (Math.random() - 0.5) * 15;
positions[node.id] = { x, y, z };
});
// 内层(核心)
const innerRadius = 12;
layers.inner.forEach((node, i) => {
// 叶子节点放在其父节点附近
const degree = nodeDegrees[node.id] || 0;
if (degree === 1) {
// 找到连接的节点
const edge = edges.find(e => e.source === node.id || e.target === node.id);
if (edge) {
const parentId = edge.source === node.id ? edge.target : edge.source;
if (positions[parentId]) {
const parentPos = positions[parentId];
const offset = 5 + Math.random() * 3;
const angle = Math.random() * Math.PI * 2;
positions[node.id] = {
x: parentPos.x + offset * Math.cos(angle),
y: parentPos.y + (Math.random() - 0.5) * 4,
z: parentPos.z + offset * Math.sin(angle)
};
return;
}
}
}
// 其他节点均匀分布在核心区域
const angle = (i / layers.inner.length) * Math.PI * 2;
const x = innerRadius * Math.cos(angle);
const z = innerRadius * Math.sin(angle);
const y = (Math.random() - 0.5) * 8;
positions[node.id] = { x, y, z };
});
// 简单力导向模拟(迭代几次)
for (let iter = 0; iter < 30; iter++) {
// 斥力:节点之间互相排斥
Object.keys(positions).forEach(id1 => {
Object.keys(positions).forEach(id2 => {
if (id1 >= id2) return;
const pos1 = positions[id1];
const pos2 = positions[id2];
const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
const dz = pos1.z - pos2.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist < 10) {
const force = 0.04 / Math.max(dist, 0.5);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
const fz = (dz / dist) * force;
pos1.x += fx;
pos1.y += fy;
pos1.z += fz;
pos2.x -= fx;
pos2.y -= fy;
pos2.z -= fz;
}
});
});
// 引力:连接的节点互相吸引
edges.forEach(edge => {
const pos1 = positions[edge.source];
const pos2 = positions[edge.target];
if (!pos1 || !pos2) return;
const dx = pos2.x - pos1.x;
const dy = pos2.y - pos1.y;
const dz = pos2.z - pos1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist > 15) {
const force = 0.05;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
const fz = (dz / dist) * force;
pos1.x += fx;
pos1.y += fy;
pos1.z += fz;
pos2.x -= fx;
pos2.y -= fy;
pos2.z -= fz;
}
});
}
// 创建节点(球体)
nodes.forEach(node => {
const pos = positions[node.id];
if (!pos) return;
// 计算球体半径
const radius = 0.4 + Math.min(node.mention_count * 0.15, 1.5);
const color = typeColors[node.type] || defaultColor;
// 根据 mention_count 计算 emissiveIntensity
const baseEmissiveIntensity = 0.5 + Math.min(node.mention_count * 0.05, 0.3);
// 创建球体
const geometry = new THREE.SphereGeometry(radius, 16, 12);
const material = new THREE.MeshPhongMaterial({
color: color,
emissive: color,
emissiveIntensity: baseEmissiveIntensity,
shininess: 30
});
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(pos.x, pos.y, pos.z);
sphere.userData = { nodeId: node.id, nodeData: node, baseEmissive: 0.5 + Math.min(node.mention_count * 0.05, 0.3) };
// 创建外层发光球体圆形光晕替代方形Sprite
const glowRadius = radius * 1.15;
const glowGeometry = new THREE.SphereGeometry(glowRadius, 16, 12);
const glowMaterial = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
opacity: 0.15,
side: THREE.BackSide,
blending: THREE.AdditiveBlending
});
const glowSphere = new THREE.Mesh(glowGeometry, glowMaterial);
sphere.add(glowSphere);
sphere.userData.glowSphere = glowSphere;
// 创建标签(改进样式)
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 64;
// 显式清空画布为透明
context.clearRect(0, 0, canvas.width, canvas.height);
// 文字带发光效果
context.font = 'bold 24px Courier New';
context.fillStyle = '#ffffff';
context.textAlign = 'center';
context.shadowColor = '#aaccff';
context.shadowBlur = 8;
context.fillText(node.name.substring(0, 12), 128, 40);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
const labelMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.9,
depthTest: false,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const label = new THREE.Sprite(labelMaterial);
label.scale.set(8, 2, 1);
label.position.y = radius + 2;
sphere.add(label);
scene.add(sphere);
nodeMeshes.push(sphere);
});
// 创建边(线条)
edges.forEach(edge => {
const pos1 = positions[edge.source];
const pos2 = positions[edge.target];
if (!pos1 || !pos2) return;
const color = edgeColors[edge.relation_type] || defaultEdgeColor;
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(pos1.x, pos1.y, pos1.z),
new THREE.Vector3(pos2.x, pos2.y, pos2.z)
]);
const material = new THREE.LineBasicMaterial({
color: color,
transparent: true,
opacity: 0.4,
linewidth: 1
});
const line = new THREE.Line(geometry, material);
line.userData = { edgeId: edge.id, edgeData: edge };
scene.add(line);
edgeLines.push(line);
});
// 调整相机位置以适应所有节点
const allPositions = Object.values(positions);
if (allPositions.length > 0) {
let maxDist = 0;
allPositions.forEach(pos => {
const dist = Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z);
maxDist = Math.max(maxDist, dist);
});
// 相机距离:节点范围加 20u 余量,但不超过 120u不低于 25u
const targetDist = Math.min(Math.max(maxDist + 15, 25), 120);
// 如果有待聚焦节点且自动视角开启,飞过去;否则全局定位
if (_pendingFocusNodeId) {
const focusId = _pendingFocusNodeId;
_pendingFocusNodeId = null;
setTimeout(() => flyToNode(focusId, 800), 100);
} else {
camera.position.set(targetDist * 0.9, targetDist * 0.6, targetDist * 0.9);
}
controls.target.set(0, 0, 0);
controls.update();
}
}
// 鼠标移动事件
function onMouseMove(event) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// 射线检测
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(nodeMeshes);
if (intersects.length > 0) {
const node = intersects[0].object;
if (hoveredNode !== node) {
// 取消之前的高亮
if (hoveredNode) {
hoveredNode.scale.set(1, 1, 1);
}
hoveredNode = node;
// 放大效果
node.scale.set(1.2, 1.2, 1.2);
// 显示信息
showNodeInfo(node.userData.nodeData);
}
} else {
if (hoveredNode) {
hoveredNode.scale.set(1, 1, 1);
hoveredNode = null;
hideNodeInfo();
}
}
}
// 鼠标点击事件
function onMouseClick(event) {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(nodeMeshes);
if (intersects.length > 0) {
const node = intersects[0].object;
if (selectedNode === node) {
selectedNode = null;
document.getElementById('node-info').style.display = 'none';
} else {
selectedNode = node;
showNodeInfo(node.userData.nodeData, true);
// 自动视角:点击节点时飞向它
if (autoViewEnabled) {
flyToNode(node.userData.nodeId, 500);
}
}
}
}
// 显示节点信息
function showNodeInfo(nodeData, isClick = false) {
const infoDiv = document.getElementById('node-info');
document.getElementById('info-name').textContent = nodeData.name;
document.getElementById('info-type').textContent = nodeData.type;
document.getElementById('info-mentions').textContent = nodeData.mention_count;
// 计算连接数
const linkCount = edges.filter(e => e.source === nodeData.id || e.target === nodeData.id).length;
document.getElementById('info-links').textContent = linkCount;
if (isClick) {
infoDiv.style.display = 'block';
}
}
// 隐藏节点信息
function hideNodeInfo() {
if (!selectedNode) {
document.getElementById('node-info').style.display = 'none';
}
}
// 窗口大小调整
function onWindowResize() {
const container = document.getElementById('canvas-container');
const rect = container.getBoundingClientRect();
const w = rect.width || window.innerWidth;
const h = rect.height || window.innerHeight;
if (w > 0 && h > 0) {
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
}
// 动画循环
function animate() {
animationId = requestAnimationFrame(animate);
const time = Date.now() * 0.001;
highlightPulse = (highlightPulse + 0.02) % (Math.PI * 2);
// 更新控制器
controls.update();
// 星空旋转(缓慢)
if (starField) {
starField.rotation.y += 0.0001;
}
// 高亮脉冲效果
if (hoveredNode) {
const pulse = 1 + Math.sin(highlightPulse * 3) * 0.05;
hoveredNode.scale.set(pulse * 1.2, pulse * 1.2, pulse * 1.2);
}
// 检查高亮每3秒
if (time - lastHighlightCheck > 3) {
checkHighlight();
lastHighlightCheck = time;
}
// 高亮动画
updateHighlight();
renderer.render(scene, camera);
}
// 已消费的高亮变更ID防止重复触发
let _pendingFocusNodeId = null;
let _consumedHighlightIds = new Set();
// 平滑重新定位剩余的节点
let _smoothAnimCount = 0;
function smoothReposition() {
if (nodeMeshes.length < 2) return;
_smoothAnimCount = nodeMeshes.length;
// 从当前节点位置计算新布局
const currentPositions = {};
nodeMeshes.forEach(m => {
const nid = m.userData.nodeId;
currentPositions[nid] = {
x: m.position.x,
y: m.position.y,
z: m.position.z
};
});
// 用力导向模拟迭代 30 次平滑
for (let iter = 0; iter < 30; iter++) {
// 斥力
const ids = Object.keys(currentPositions);
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = currentPositions[ids[i]];
const b = currentPositions[ids[j]];
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist < 12) {
const force = 0.08 / Math.max(dist, 0.5);
a.x += (dx/dist) * force;
a.y += (dy/dist) * force;
a.z += (dz/dist) * force;
b.x -= (dx/dist) * force;
b.y -= (dy/dist) * force;
b.z -= (dz/dist) * force;
}
}
}
// 引力
edges.forEach(e => {
const p1 = currentPositions[e.source];
const p2 = currentPositions[e.target];
if (!p1 || !p2) return;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dz = p2.z - p1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist > 8) {
const force = 0.03;
p1.x += (dx/dist) * force;
p1.y += (dy/dist) * force;
p1.z += (dz/dist) * force;
p2.x -= (dx/dist) * force;
p2.y -= (dy/dist) * force;
p2.z -= (dz/dist) * force;
}
});
}
// 平滑移动到新位置
nodeMeshes.forEach(m => {
const nid = m.userData.nodeId;
const target = currentPositions[nid];
if (!target) return;
// 使用简单插值动画从当前位置到目标
const startPos = m.position.clone();
const endPos = new THREE.Vector3(target.x, target.y, target.z);
const duration = 600;
const startTime = Date.now();
function lerpPosition() {
const elapsed = Date.now() - startTime;
const t = Math.min(elapsed / duration, 1);
// ease-out cubic
const ease = 1 - Math.pow(1 - t, 3);
m.position.lerpVectors(startPos, endPos, ease);
if (t >= 1) {
_smoothAnimCount--;
// 所有动画完成后再重建粒子
if (_smoothAnimCount <= 0) {
Object.keys(edgeParticles).forEach(key => {
scene.remove(edgeParticles[key].mesh);
});
edgeParticles = {};
edges.forEach(e => createEdgeParticle(e));
}
}
if (t < 1) requestAnimationFrame(lerpPosition);
}
lerpPosition();
});
// 不在此处更新边的几何——让粒子在动画完成后重建
// 边线在下一轮 loadGraphData 时会自动更新
// 更新相机
fitCameraToGraph();
}
// 自适应相机
function fitCameraToGraph() {
if (nodeMeshes.length === 0) return;
if (!autoViewEnabled) {
// 关闭自动视角时直接跳转
let maxDist = 0;
nodeMeshes.forEach(m => {
const d = m.position.length();
if (d > maxDist) maxDist = d;
});
if (maxDist < 1) maxDist = 30;
const targetDist = Math.min(Math.max(maxDist + 15, 25), 120);
camera.position.set(targetDist * 0.9, targetDist * 0.6, targetDist * 0.9);
controls.target.set(0, 0, 0);
controls.update();
return;
}
let maxDist = 0;
nodeMeshes.forEach(m => {
const d = m.position.length();
if (d > maxDist) maxDist = d;
});
if (maxDist < 1) maxDist = 30;
const targetDist = Math.min(Math.max(maxDist + 15, 25), 120);
// 平滑移动相机
const startPos = camera.position.clone();
const endPos = new THREE.Vector3(targetDist * 0.9, targetDist * 0.6, targetDist * 0.9);
const duration = 400;
const startTime = Date.now();
function lerpCamera() {
const elapsed = Date.now() - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
camera.position.lerpVectors(startPos, endPos, ease);
controls.target.set(0, 0, 0);
if (t < 1) requestAnimationFrame(lerpCamera);
}
lerpCamera();
}
// 飞向指定节点(自动视角)
function flyToNode(nodeId, duration = 600) {
if (!autoViewEnabled) return;
const mesh = nodeMeshes.find(m => m.userData.nodeId === nodeId);
if (!mesh) return;
const targetPos = mesh.position.clone();
const startPos = camera.position.clone();
const startTarget = controls.target.clone();
const distance = targetPos.length() + 25;
const endCamPos = new THREE.Vector3(targetPos.x, targetPos.y + distance * 0.4, targetPos.z + distance * 0.8);
const startTime = Date.now();
function lerp() {
const elapsed = Date.now() - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
camera.position.lerpVectors(startPos, endCamPos, ease);
controls.target.lerpVectors(startTarget, targetPos, ease);
if (t < 1) requestAnimationFrame(lerp);
}
lerp();
}
// 检查高亮 — 增量更新,不再全量 reload
async function checkHighlight() {
try {
const response = await fetch('/api/graph/highlight');
const data = await response.json();
if (data.success && data.highlight_ids) {
currentHighlightIds = data.highlight_ids;
// 计算所有需要高亮的边ID
currentHighlightEdgeIds.clear();
currentHighlightIds.forEach(nodeId => {
edgeLines.forEach(line => {
const edgeData = line.userData.edgeData;
if (edgeData.source === nodeId || edgeData.target === nodeId) {
currentHighlightEdgeIds.add(edgeData.id);
}
});
});
// 自动视角:高亮节点被读取时,飞向它们的中心
if (autoViewEnabled && currentHighlightIds.length > 0 && !data.new_node_id) {
const meshes = nodeMeshes.filter(m => currentHighlightIds.includes(m.userData.nodeId));
if (meshes.length > 0) {
const avgPos = new THREE.Vector3(0, 0, 0);
meshes.forEach(m => avgPos.add(m.position));
avgPos.divideScalar(meshes.length);
const distance = avgPos.length() + 25;
const startPos = camera.position.clone();
const endCamPos = new THREE.Vector3(avgPos.x, avgPos.y + distance * 0.4, avgPos.z + distance * 0.8);
const startTarget = controls.target.clone();
const duration = 600;
const startTime = Date.now();
function lerpHighlight() {
const elapsed = Date.now() - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
camera.position.lerpVectors(startPos, endCamPos, ease);
controls.target.lerpVectors(startTarget, avgPos, ease);
if (t < 1) requestAnimationFrame(lerpHighlight);
}
lerpHighlight();
}
}
}
if (!data.success) return;
// 处理新节点
if (data.new_node_id && !_consumedHighlightIds.has('new:' + data.new_node_id)) {
_consumedHighlightIds.add('new:' + data.new_node_id);
// 新节点不在当前场景中,重新加载完整图
_pendingFocusNodeId = data.new_node_id;
loadGraphData();
setTimeout(showDebugInfo, 500);
return;
}
// 处理删除的节点 — 增量更新,不要全量 reload
if (data.deleted_node_ids && data.deleted_node_ids.length > 0) {
const pending = data.deleted_node_ids.filter(id => !_consumedHighlightIds.has('del:' + id));
if (pending.length > 0) {
pending.forEach(id => _consumedHighlightIds.add('del:' + id));
pending.forEach(nodeId => deleteNodeAnimation(nodeId));
// 动画完成后平滑重新布局
setTimeout(() => {
// 从 edges 和 nodes 中移除已删除的
nodes = nodes.filter(n => !pending.includes(n.id));
edges = edges.filter(e => !pending.includes(e.source) && !pending.includes(e.target));
smoothReposition();
// 更新统计
document.getElementById('node-count').textContent = nodes.length;
document.getElementById('edge-count').textContent = edges.length;
}, 1000);
}
}
} catch (error) {
// 忽略错误
}
}
// 更新高亮效果
function updateHighlight() {
nodeMeshes.forEach(mesh => {
const nodeId = mesh.userData.nodeId;
const isHighlighted = currentHighlightIds.includes(nodeId);
const baseEmissiveIntensity = 0.5 + Math.min(mesh.userData.nodeData.mention_count * 0.05, 0.3);
if (isHighlighted) {
// 脉冲缩放效果
const pulse = 1 + Math.sin(highlightPulse * 4) * 0.1;
mesh.scale.set(pulse, pulse, pulse);
// 大幅增加发光
if (mesh.material) {
mesh.material.emissiveIntensity = 1.2;
}
// 外层发光球体增强
if (mesh.userData.glowSphere) {
mesh.userData.glowSphere.material.opacity = 0.5;
}
} else {
// 恢复正常
if (mesh !== hoveredNode) {
mesh.scale.set(1, 1, 1);
}
if (mesh.material && mesh !== hoveredNode) {
mesh.material.emissiveIntensity = baseEmissiveIntensity;
}
// 外层发光球体恢复
if (mesh.userData.glowSphere && mesh !== hoveredNode) {
mesh.userData.glowSphere.material.opacity = 0.15;
}
}
});
// 处理边的高亮
edgeLines.forEach(line => {
const edgeId = line.userData.edgeId;
const edgeData = line.userData.edgeData;
const isHighlighted = currentHighlightEdgeIds.has(edgeId);
if (isHighlighted) {
// 边高亮透明度增加到1.0,颜色变亮
if (line.material) {
line.material.opacity = 1.0;
line.material.color.setHex(0xffffff);
}
// 添加流动光点(如果还没有)
if (!edgeParticles[edgeId]) {
createEdgeParticle(edgeData);
}
} else {
// 恢复边的原始颜色
if (line.material) {
line.material.opacity = 0.4;
const originalColor = edgeColors[edgeData.relation_type] || defaultEdgeColor;
line.material.color.setHex(originalColor);
}
// 移除流动光点
if (edgeParticles[edgeId]) {
removeEdgeParticle(edgeId);
}
}
});
// 更新流动光点位置
updateEdgeParticles();
}
// 创建边的流动光点
function createEdgeParticle(edgeData) {
const pos1 = getNodePosition(edgeData.source);
const pos2 = getNodePosition(edgeData.target);
if (!pos1 || !pos2) return;
const geometry = new THREE.SphereGeometry(0.15, 8, 8);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.8
});
const particle = new THREE.Mesh(geometry, material);
particle.position.copy(pos1);
scene.add(particle);
edgeParticles[edgeData.id] = {
mesh: particle,
progress: 0,
edge: edgeData,
pos1: pos1,
pos2: pos2
};
}
// 移除边的流动光点
function removeEdgeParticle(edgeId) {
if (edgeParticles[edgeId]) {
scene.remove(edgeParticles[edgeId].mesh);
delete edgeParticles[edgeId];
}
}
// 更新流动光点位置
function updateEdgeParticles() {
Object.keys(edgeParticles).forEach(edgeId => {
const ep = edgeParticles[edgeId];
ep.progress += 0.01;
if (ep.progress > 1) ep.progress = 0;
// 线性插值计算位置
ep.mesh.position.x = ep.pos1.x + (ep.pos2.x - ep.pos1.x) * ep.progress;
ep.mesh.position.y = ep.pos1.y + (ep.pos2.y - ep.pos1.y) * ep.progress;
ep.mesh.position.z = ep.pos1.z + (ep.pos2.z - ep.pos1.z) * ep.progress;
// 光点脉冲效果
const pulse = 0.8 + Math.sin(highlightPulse * 8) * 0.2;
ep.mesh.material.opacity = pulse;
});
}
// 获取节点位置(辅助函数)
function getNodePosition(nodeId) {
const mesh = nodeMeshes.find(m => m.userData.nodeId === nodeId);
return mesh ? mesh.position : null;
}
// 新节点诞生动画(由无穷小变大,并连线)
function flashNewNode(nodeId) {
const mesh = nodeMeshes.find(m => m.userData.nodeId === nodeId);
if (!mesh) return;
// 设置初始状态:无穷小,白色
mesh.scale.set(0.001, 0.001, 0.001);
const originalColor = mesh.material.color.clone();
mesh.material.color.setHex(0xffffff);
mesh.material.emissive.setHex(0xffffff);
// 获取与该节点相连的边
const connectedEdges = edges.filter(e => e.source === nodeId || e.target === nodeId);
// 动画参数
const duration = 500; // 0.5秒
const startTime = Date.now();
// 光线动画数组
const lightAnimations = [];
// 创建光线动画
connectedEdges.forEach(edge => {
const targetId = edge.source === nodeId ? edge.target : edge.source;
const targetMesh = nodeMeshes.find(m => m.userData.nodeId === targetId);
if (!targetMesh) return;
const sourcePos = mesh.position.clone();
const targetPos = targetMesh.position.clone();
// 创建光线(白色细线)
const geometry = new THREE.BufferGeometry().setFromPoints([
sourcePos.clone(),
sourcePos.clone()
]);
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1.0,
linewidth: 2
});
const line = new THREE.Line(geometry, material);
scene.add(line);
lightAnimations.push({
line: line,
sourcePos: sourcePos,
targetPos: targetPos,
startTime: Date.now() + 300, // 0.3秒后开始
duration: 300,
edge: edge,
completed: false
});
});
// 节点放大动画
function animateNewNode() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// 从0.001放大到1线性
const scale = 0.001 + progress * 0.999;
mesh.scale.set(scale, scale, scale);
// 颜色从白色渐变到正常颜色
if (progress < 1) {
const colorProgress = progress;
mesh.material.color.setHex(0xffffff).lerp(originalColor, colorProgress);
mesh.material.emissive.setHex(0xffffff).lerp(originalColor, colorProgress);
} else {
mesh.material.color.copy(originalColor);
mesh.material.emissive.copy(originalColor);
}
// 处理光线动画
lightAnimations.forEach(la => {
if (la.completed) return;
const now = Date.now();
if (now < la.startTime) return;
const lightElapsed = now - la.startTime;
const lightProgress = Math.min(lightElapsed / la.duration, 1);
// 更新光线终点
const currentEnd = la.sourcePos.clone().lerp(la.targetPos, lightProgress);
la.line.geometry.setFromPoints([la.sourcePos, currentEnd]);
if (lightProgress >= 1) {
la.completed = true;
// 光线到达后,创建正式边(如果还不存在)
const edgeExists = edgeLines.some(el => el.userData.edgeId === la.edge.id);
if (!edgeExists) {
const positions = {};
nodeMeshes.forEach(m => {
positions[m.userData.nodeId] = m.position;
});
const pos1 = positions[la.edge.source];
const pos2 = positions[la.edge.target];
if (pos1 && pos2) {
const color = edgeColors[la.edge.relation_type] || defaultEdgeColor;
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(pos1.x, pos1.y, pos1.z),
new THREE.Vector3(pos2.x, pos2.y, pos2.z)
]);
const material = new THREE.LineBasicMaterial({
color: color,
transparent: true,
opacity: 0.4,
linewidth: 1
});
const line = new THREE.Line(geometry, material);
line.userData = { edgeId: la.edge.id, edgeData: la.edge };
scene.add(line);
edgeLines.push(line);
}
}
// 移除光线
scene.remove(la.line);
la.line.geometry.dispose();
la.line.material.dispose();
}
});
if (progress < 1 || lightAnimations.some(la => !la.completed)) {
requestAnimationFrame(animateNewNode);
}
}
animateNewNode();
}
// 节点删除消散动画
function deleteNodeAnimation(nodeId) {
const mesh = nodeMeshes.find(m => m.userData.nodeId === nodeId);
if (!mesh) return;
const nodeColor = mesh.material.color.clone();
const nodePosition = mesh.position.clone();
// 创建爆散粒子30-50个
const particleCount = 30 + Math.floor(Math.random() * 20);
const particles = [];
for (let i = 0; i < particleCount; i++) {
const geometry = new THREE.SphereGeometry(0.15, 4, 4);
const material = new THREE.MeshBasicMaterial({
color: nodeColor,
transparent: true,
opacity: 1.0
});
const particle = new THREE.Mesh(geometry, material);
particle.position.copy(nodePosition);
particle.userData = {
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
),
life: 1.0
};
scene.add(particle);
particles.push(particle);
}
// 找到并淡出所有连接该节点的边
const connectedEdges = edgeLines.filter(line => {
const edgeData = line.userData.edgeData;
return edgeData.source === nodeId || edgeData.target === nodeId;
});
// 边的淡出动画
connectedEdges.forEach(line => {
line.userData.fadeOut = true;
});
// 隐藏节点(但保留在场景中用于动画)
mesh.visible = false;
// 动画持续时间
const duration = 800; // 0.8秒
const startTime = Date.now();
function animateDeletion() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// 更新粒子
particles.forEach(p => {
if (p.userData.life <= 0) return;
p.position.add(p.userData.velocity.clone().multiplyScalar(0.05));
p.userData.life -= 0.02;
p.material.opacity = Math.max(0, p.userData.life);
const scale = Math.max(0.01, p.userData.life);
p.scale.setScalar(scale);
});
// 更新边的淡出
connectedEdges.forEach(line => {
if (line.material) {
line.material.opacity = Math.max(0, 0.4 * (1 - progress));
}
});
if (progress < 1) {
requestAnimationFrame(animateDeletion);
} else {
// 动画结束,移除所有元素
particles.forEach(p => scene.remove(p));
// 移除节点
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
// 移除边并清理
connectedEdges.forEach(line => {
scene.remove(line);
line.geometry.dispose();
line.material.dispose();
});
// 从数组中移除
const meshIndex = nodeMeshes.indexOf(mesh);
if (meshIndex > -1) {
nodeMeshes.splice(meshIndex, 1);
}
const edgeIndices = [];
connectedEdges.forEach(line => {
const idx = edgeLines.indexOf(line);
if (idx > -1) edgeIndices.push(idx);
});
edgeIndices.sort((a, b) => b - a).forEach(idx => {
edgeLines.splice(idx, 1);
});
}
}
animateDeletion();
}
// ===== 聊天面板功能 =====
const chatInput = document.getElementById('chatInput');
const chatSendBtn = document.getElementById('chatSendBtn');
const chatMessages = document.getElementById('chatMessages');
const chatStatus = document.getElementById('chatStatus');
const chatCloseBtn = document.getElementById('chatCloseBtn');
const toggleChat = document.getElementById('toggle-chat');
const chatPanel = document.getElementById('chat-panel');
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const chatFilesContainer = document.getElementById('chatFilesContainer');
let attachedFiles = [];
function getFileIcon(name) {
const lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return '📕';
if (lower.endsWith('.docx') || lower.endsWith('.doc')) return '📘';
if (lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.csv')) return '📊';
if (lower.endsWith('.pptx') || lower.endsWith('.ppt')) return '📙';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.png') || lower.endsWith('.gif') || lower.endsWith('.webp')) return '🖼️';
if (lower.endsWith('.json') || lower.endsWith('.xml') || lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.toml')) return '⚙️';
if (lower.endsWith('.py') || lower.endsWith('.js') || lower.endsWith('.ts') || lower.endsWith('.java') || lower.endsWith('.c') || lower.endsWith('.cpp') || lower.endsWith('.h') || lower.endsWith('.sh') || lower.endsWith('.bat') || lower.endsWith('.ps1')) return '💻';
return '📄';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
}
function getFileKey(file) {
return file.name + '_' + file.size;
}
function addFileChip(file) {
const key = getFileKey(file);
if (attachedFiles.some(f => getFileKey(f) === key)) return;
attachedFiles.push(file);
renderFileChips();
}
function removeFileChip(file) {
const key = getFileKey(file);
attachedFiles = attachedFiles.filter(f => getFileKey(f) !== key);
renderFileChips();
}
function renderFileChips() {
chatFilesContainer.innerHTML = '';
attachedFiles.forEach(file => {
const chip = document.createElement('div');
chip.className = 'chat-file-chip';
const icon = getFileIcon(file.name);
chip.innerHTML = `<span class="file-icon">${icon}</span><span class="file-name" title="${file.name}">${file.name}</span><span class="file-size">${formatFileSize(file.size)}</span><span class="file-remove" title="删除">✕</span>`;
chip.querySelector('.file-remove').onclick = () => removeFileChip(file);
chatFilesContainer.appendChild(chip);
});
}
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
Array.from(fileInput.files).forEach(addFileChip);
fileInput.value = '';
});
chatMessages.addEventListener('dragover', e => {
e.preventDefault();
chatMessages.classList.add('drag-over');
});
chatMessages.addEventListener('dragleave', () => {
chatMessages.classList.remove('drag-over');
});
chatMessages.addEventListener('drop', e => {
e.preventDefault();
chatMessages.classList.remove('drag-over');
Array.from(e.dataTransfer.files).forEach(addFileChip);
});
let chatCollapsed = false;
toggleChat.addEventListener('click', () => {
chatCollapsed = !chatCollapsed;
chatPanel.classList.toggle('collapsed', chatCollapsed);
toggleChat.innerHTML = chatCollapsed ? "<i class=\"fas fa-paperclip\"></i>" : "<i class=\"fas fa-thumbtack\"></i>";
setTimeout(onWindowResize, 300);
});
chatCloseBtn.addEventListener('click', () => {
chatCollapsed = true;
chatPanel.classList.add('collapsed');
toggleChat.innerHTML = '<i class="fas fa-paperclip"></i>';
setTimeout(onWindowResize, 300);
});
function addChatMessage(role, text) {
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
const div = document.createElement('div');
div.className = 'chat-msg ' + role;
// 解析工具调用详情
let displayText = text;
try {
const parsed = JSON.parse(text);
if (parsed.content) displayText = parsed.content;
} catch(e) {}
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-content';
contentDiv.innerHTML = renderMarkdown(displayText);
div.appendChild(contentDiv);
const time = document.createElement('div');
time.className = 'chat-msg-time';
time.textContent = new Date().toLocaleTimeString();
div.appendChild(time);
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
async function loadChatHistory() {
try {
const res = await fetch('/api/history');
const data = await res.json();
if (data.success && data.history && Array.isArray(data.history)) {
data.history.forEach(msg => {
if (msg.role && msg.content) {
addChatMessage(msg.role, msg.content);
} else if (typeof msg === 'object') {
const role = msg.from === 'user' ? 'user' : 'assistant';
const content = msg.content || msg.text || msg.message || '';
if (content) addChatMessage(role, content);
}
});
}
} catch(e) {
console.warn('Failed to load chat history:', e);
}
}
async function readFileContent(file) {
const name = file.name.toLowerCase();
// PDF 文件:用 pdf.js 解析
if (name.endsWith('.pdf')) {
if (typeof pdfjsLib === 'undefined') throw new Error('PDF.js 未加载');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const pages = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
const text = content.items.map(item => item.str).join(' ');
pages.push(text);
}
return pages.join('\n\n');
}
// Word 文件:用 mammoth.js 解析
if (name.endsWith('.docx')) {
if (typeof mammoth === 'undefined') throw new Error('mammoth.js 未加载');
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
return result.value || '(Word 文档内容为空)';
}
// 其他文件:当作纯文本读取
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
});
}
function buildFullMessage(text, files) {
if (!files || files.length === 0) return text;
let parts = [];
files.forEach(file => {
parts.push(`文件名:${file.name}`);
parts.push(`内容:`);
parts.push(file.content);
parts.push('---');
});
parts.push(text);
return parts.join('\n');
}
async function sendChatMessage() {
const files = attachedFiles.slice();
const msg = chatInput.value.trim();
if (!msg && files.length === 0) return;
chatInput.value = '';
// 读取文件内容
let fullMsg = msg;
if (files.length > 0) {
const fileReads = files.map(async (file) => {
try {
file.content = await readFileContent(file);
} catch(e) {
file.content = `[文件读取失败: ${file.name}] ${e.message}`;
}
});
await Promise.all(fileReads);
fullMsg = buildFullMessage(msg, files);
// 清除文件芯片
attachedFiles = [];
renderFileChips();
}
addChatMessage('user', fullMsg);
chatInput.disabled = true;
chatSendBtn.disabled = true;
chatStatus.textContent = '⏳ 处理中...';
chatStatus.className = 'chat-status processing';
try {
const res = await fetch('/api/message', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: fullMsg})
});
const data = await res.json();
if (data.success) {
const reply = data.content || data.data?.content || '(无回复)';
addChatMessage('assistant', reply);
chatStatus.innerHTML = '<span class="status-dot" style="color:#22c55e">●</span> 已连接';
chatStatus.className = 'chat-status';
} else {
addChatMessage('assistant', '❌ ' + (data.error || '请求失败'));
chatStatus.innerHTML = '<span class="status-dot" style="color:#ef4444">●</span> 错误: ' + (data.error || 'unknown');
chatStatus.className = 'chat-status error';
}
} catch(e) {
addChatMessage('assistant', '❌ 网络错误: ' + e.message);
chatStatus.innerHTML = '<span class="status-dot" style="color:#ef4444">●</span> 网络错误';
chatStatus.className = 'chat-status error';
}
chatInput.disabled = false;
chatSendBtn.disabled = false;
chatInput.focus();
}
chatSendBtn.addEventListener('click', sendChatMessage);
chatInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
});
loadChatHistory();
// ===== 聊天面板结束 =====
// 启动
init();
</script>
</body>
</html>