本轮实现功能: 1. 多用户隔离:每个用户独立 config.json + graph.db 2. TUI 登录页 + 旧版自动迁移(core/migrate.py) 3. Admin/User 角色体系(core/embedded_db.py) 4. Web 后台管理 API(userinfo + admin CRUD) 5. Web 设置页用户管理区(仅 admin 可见) 6. TUI 侧栏配置区权限同步(非 admin 隐藏 Web 服务设置) 7. Web 服务打包为独立二进制(trulymem-web) 8. 双入口 PyInstaller 构建脚本(TUI + Web) 9. 活动记录器(core/activity_recorder.py) 10. 静态页面模板(登录/设置/首次引导)
1502 lines
54 KiB
HTML
1502 lines
54 KiB
HTML
<!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>
|
||
<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;
|
||
}
|
||
#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: 12px 16px;
|
||
border-top: 1px solid rgba(100, 100, 255, 0.15);
|
||
display: flex;
|
||
gap: 8px;
|
||
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);
|
||
}
|
||
.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%;
|
||
}
|
||
}
|
||
|
||
</style>
|
||
</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>🌌 记忆星图</h3>
|
||
<p>节点: <span id="node-count">0</span></p>
|
||
<p>边: <span id="edge-count">0</span></p>
|
||
<p>状态: <span id="status">初始化中...</span></p>
|
||
</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">🌌 星图</a>
|
||
<a href="/#activity" class="nav-btn">📡 活动</a>
|
||
<a href="/settings" class="nav-btn">⚙ 设置</a>
|
||
</div>
|
||
</div>
|
||
<!-- 聊天面板 -->
|
||
<div id="chat-panel">
|
||
<div class="chat-header">
|
||
<span>💬 与 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 id="chatSendBtn">发送</button>
|
||
</div>
|
||
<div class="chat-status" id="chatStatus">🟢 已连接</div>
|
||
</div>
|
||
<button id="toggle-chat">📌</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')
|
||
// 全局变量
|
||
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 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();
|
||
}
|
||
|
||
// 创建星空背景
|
||
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));
|
||
nodeMeshes = [];
|
||
edgeLines = [];
|
||
|
||
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 < 50; 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.1 / dist;
|
||
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);
|
||
});
|
||
camera.position.set(maxDist * 2.2, maxDist * 1.5, maxDist * 2.2);
|
||
controls.target.set(0, 0, 0);
|
||
}
|
||
}
|
||
|
||
// 鼠标移动事件
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 显示节点信息
|
||
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);
|
||
}
|
||
|
||
// 检查高亮(每2秒)
|
||
if (time - lastHighlightCheck > 2) {
|
||
checkHighlight();
|
||
lastHighlightCheck = time;
|
||
}
|
||
|
||
// 高亮动画
|
||
updateHighlight();
|
||
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
// 检查高亮
|
||
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 (data.new_node_id) {
|
||
flashNewNode(data.new_node_id);
|
||
}
|
||
|
||
// 处理删除的节点
|
||
if (data.deleted_node_ids && data.deleted_node_ids.length > 0) {
|
||
data.deleted_node_ids.forEach(nodeId => {
|
||
deleteNodeAnimation(nodeId);
|
||
});
|
||
// 延迟刷新图数据,等待动画完成
|
||
setTimeout(() => {
|
||
loadGraphData();
|
||
setTimeout(showDebugInfo, 500);
|
||
}, 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');
|
||
|
||
let chatCollapsed = false;
|
||
toggleChat.addEventListener('click', () => {
|
||
chatCollapsed = !chatCollapsed;
|
||
chatPanel.classList.toggle('collapsed', chatCollapsed);
|
||
toggleChat.textContent = chatCollapsed ? '📎' : '📌';
|
||
setTimeout(onWindowResize, 300);
|
||
});
|
||
chatCloseBtn.addEventListener('click', () => {
|
||
chatCollapsed = true;
|
||
chatPanel.classList.add('collapsed');
|
||
toggleChat.textContent = '📎';
|
||
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) {}
|
||
|
||
div.textContent = displayText;
|
||
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 sendChatMessage() {
|
||
const msg = chatInput.value.trim();
|
||
if (!msg) return;
|
||
chatInput.value = '';
|
||
addChatMessage('user', msg);
|
||
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: msg})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
const reply = data.data?.content || '(无回复)';
|
||
addChatMessage('assistant', reply);
|
||
chatStatus.textContent = '🟢 已连接';
|
||
chatStatus.className = 'chat-status';
|
||
} else {
|
||
addChatMessage('assistant', '❌ ' + (data.error || '请求失败'));
|
||
chatStatus.textContent = '🔴 错误: ' + (data.error || 'unknown');
|
||
chatStatus.className = 'chat-status error';
|
||
}
|
||
} catch(e) {
|
||
addChatMessage('assistant', '❌ 网络错误: ' + e.message);
|
||
chatStatus.textContent = '🔴 网络错误';
|
||
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>
|