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:
root
2026-04-28 18:05:41 +08:00
parent fd2c688321
commit ac113d6fb0
72 changed files with 4770 additions and 5 deletions

View File

@ -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';

View File

@ -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

View File

@ -189,6 +189,25 @@
<div class="settings-container">
<h1 class="settings-title"><i class="fas fa-cog"></i> 设置</h1>
<!-- API 配置 -->
<div class="section-title"><i class="fas fa-plug"></i> API 配置</div>
<form id="apiConfigForm">
<div class="form-group">
<label for="settings_api_key">API Key</label>
<input type="password" id="settings_api_key" placeholder="输入 API Key">
</div>
<div class="form-group">
<label for="settings_base_url">Base URL</label>
<input type="text" id="settings_base_url" placeholder="https://api.deepseek.com">
</div>
<div class="form-group">
<label for="settings_model">Model</label>
<input type="text" id="settings_model" placeholder="deepseek-chat">
</div>
<button type="submit" class="btn" id="saveApiConfigBtn">保 存 API 配 置</button>
</form>
<div id="apiConfigStatus" style="text-align:center;font-size:12px;color:#666688;margin-top:5px;"></div>
<!-- 修改密码 -->
<div class="section-title"><i class="fas fa-key"></i> 修改密码</div>
<form id="passwordForm">
@ -451,6 +470,65 @@
init();
// API 配置加载与保存
async function loadApiConfig() {
try {
const resp = await fetch('/api/settings');
const data = await resp.json();
if (data.success && data.data) {
const cfg = data.data.api_config || {};
document.getElementById('settings_api_key').value = cfg.api_key || '';
document.getElementById('settings_base_url').value = cfg.base_url || 'https://api.deepseek.com';
document.getElementById('settings_model').value = cfg.model || 'deepseek-chat';
document.getElementById('apiConfigStatus').textContent = '✅ 配置已加载';
}
} catch (e) {
document.getElementById('apiConfigStatus').textContent = '⚠️ 无法加载配置';
}
}
document.getElementById('apiConfigForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('saveApiConfigBtn');
btn.disabled = true;
btn.textContent = '保存中...';
const statusEl = document.getElementById('apiConfigStatus');
statusEl.textContent = '🔄 保存中...';
try {
const resp = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_config: {
api_key: document.getElementById('settings_api_key').value,
base_url: document.getElementById('settings_base_url').value,
model: document.getElementById('settings_model').value
}
})
});
const data = await resp.json();
if (data.success) {
statusEl.textContent = '✅ API 配置已保存并生效';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
} else {
statusEl.textContent = '⚠️ 保存失败: ' + (data.error || '未知错误');
}
} catch (e) {
statusEl.textContent = '⚠️ 网络错误';
} finally {
btn.disabled = false;
btn.textContent = '保 存 API 配 置';
}
});
// 补充 loadConfig 中调用 loadApiConfig
const origLoadConfig = loadConfig;
loadConfig = function() {
origLoadConfig();
loadApiConfig();
};
// TUI 开关
document.getElementById('enableTuiToggle').addEventListener('change', async function() {
const enable = this.checked;