feat: add markdown rendering, API settings, star graph auto-view toggle
- index.html: markdown rendering with marked.js + DOMPurify XSS protection - settings.html: API Key/Base URL/Model config form with save - graph.html: auto camera transition toggle for add/read/delete nodes
This commit is contained in:
@ -300,7 +300,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-container">
|
||||
@ -312,6 +328,14 @@
|
||||
<p>节点: <span id="node-count">0</span></p>
|
||||
<p>边: <span id="edge-count">0</span></p>
|
||||
<p>状态: <span id="status">初始化中...</span></p>
|
||||
<p style="margin-top:8px;display:flex;align-items:center;gap:6px">
|
||||
<span style="color:#8888aa;font-size:12px">自动视角</span>
|
||||
<label style="position:relative;width:36px;height:20px;cursor:pointer;flex-shrink:0">
|
||||
<input type="checkbox" id="autoViewToggle" checked style="display:none">
|
||||
<span style="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)"></span>
|
||||
<span style="position:absolute;width:16px;height:16px;left:2px;bottom:2px;background:#6666aa;border-radius:50%;transition:all 0.3s" class="auto-slider"></span>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
<div id="node-info">
|
||||
<h3 id="info-name"></h3>
|
||||
@ -348,6 +372,17 @@
|
||||
<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 = [];
|
||||
@ -356,6 +391,7 @@
|
||||
let raycaster, mouse;
|
||||
let hoveredNode = null, selectedNode = null;
|
||||
let animationId;
|
||||
let autoViewEnabled = true;
|
||||
let highlightPulse = 0;
|
||||
let lastHighlightCheck = 0;
|
||||
let currentHighlightIds = [];
|
||||
@ -450,6 +486,12 @@ function init() {
|
||||
}
|
||||
|
||||
// 创建星空背景
|
||||
|
||||
// 自动视角开关事件
|
||||
document.getElementById("autoViewToggle").addEventListener("change", function() {
|
||||
autoViewEnabled = this.checked;
|
||||
});
|
||||
|
||||
function createStarField() {
|
||||
const starCount = 3000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
@ -847,7 +889,14 @@ function init() {
|
||||
});
|
||||
// 相机距离:节点范围加 20u 余量,但不超过 120u,不低于 25u
|
||||
const targetDist = Math.min(Math.max(maxDist + 15, 25), 120);
|
||||
camera.position.set(targetDist * 0.9, targetDist * 0.6, targetDist * 0.9);
|
||||
// 如果有待聚焦节点且自动视角开启,飞过去;否则全局定位
|
||||
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();
|
||||
}
|
||||
@ -972,6 +1021,7 @@ function init() {
|
||||
}
|
||||
|
||||
// 已消费的高亮变更ID(防止重复触发)
|
||||
let _pendingFocusNodeId = null;
|
||||
let _consumedHighlightIds = new Set();
|
||||
|
||||
// 平滑重新定位剩余的节点
|
||||
@ -1082,6 +1132,20 @@ function smoothReposition() {
|
||||
// 自适应相机
|
||||
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();
|
||||
@ -1105,6 +1169,28 @@ function smoothReposition() {
|
||||
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 {
|
||||
@ -1132,6 +1218,7 @@ function smoothReposition() {
|
||||
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;
|
||||
@ -1573,7 +1660,10 @@ function smoothReposition() {
|
||||
if (parsed.content) displayText = parsed.content;
|
||||
} catch(e) {}
|
||||
|
||||
div.textContent = displayText;
|
||||
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();
|
||||
@ -1620,7 +1710,7 @@ function smoothReposition() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const reply = data.data?.content || '(无回复)';
|
||||
const reply = data.content || data.data?.content || '(无回复)';
|
||||
addChatMessage('assistant', reply);
|
||||
chatStatus.textContent = '🟢 已连接';
|
||||
chatStatus.className = 'chat-status';
|
||||
|
||||
@ -514,7 +514,24 @@ body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.message-content h1,.message-content h2,.message-content h3{color:#e94560;margin:8px 0 4px}
|
||||
.message-content p{margin:4px 0}
|
||||
.message-content ul,.message-content ol{margin:4px 0 4px 20px}
|
||||
.message-content code{background:#12121f;color:#ffd700;padding:1px 4px;border-radius:3px;font-size:0.9em}
|
||||
.message-content pre{background:#12121f;padding:8px;border-radius:4px;overflow-x:auto;margin:8px 0;border-left:3px solid #e94560}
|
||||
.message-content pre code{background:none;color:#eee;padding:0}
|
||||
.message-content blockquote{border-left:3px solid #0f3460;padding-left:8px;margin:8px 0;color:#888}
|
||||
.message-content a{color:#4169e1;text-decoration:none}
|
||||
.message-content a:hover{text-decoration:underline}
|
||||
.message-content table{border-collapse:collapse;margin:8px 0;width:100%}
|
||||
.message-content th,.message-content td{border:1px solid #0f3460;padding:4px 8px}
|
||||
.message-content th{background:#12121f;color:#e94560}
|
||||
.message-content img{max-width:100%;border-radius:4px}
|
||||
.message-content hr{border:none;border-top:1px solid #0f3460;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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -654,6 +671,15 @@ async function init() {
|
||||
addWelcomeMessage();
|
||||
}
|
||||
|
||||
// Markdown Rendering
|
||||
function renderMarkdown(text) {
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
return DOMPurify.sanitize(marked.parse(text));
|
||||
}
|
||||
return text.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// API Functions
|
||||
async function apiRequest(endpoint, method = 'GET', data = null) {
|
||||
const options = {
|
||||
@ -739,7 +765,7 @@ function addMessageToUI(role, content, toolCalls = null) {
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'message-content';
|
||||
contentEl.textContent = content;
|
||||
contentEl.innerHTML = marked.parse(content);
|
||||
|
||||
widget.appendChild(header);
|
||||
widget.appendChild(contentEl);
|
||||
@ -790,7 +816,7 @@ function updateLatestMessage(content, toolCalls = null) {
|
||||
const latest = widgets[widgets.length - 1];
|
||||
const contentEl = latest.querySelector('.message-content');
|
||||
if (contentEl) {
|
||||
contentEl.textContent = content;
|
||||
contentEl.innerHTML = marked.parse(content);
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
|
||||
Reference in New Issue
Block a user