Files
TrulyMEM-TrueHumanMEM-local/static/graph.html
root b4456a9c5b 多用户系统 + Admin 角色权限 + Web/TUI 同步 + 构建集成
本轮实现功能:
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. 静态页面模板(登录/设置/首次引导)
2026-04-28 10:37:57 +08:00

1502 lines
54 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>
<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>