From b768d02987a3e9f50ed541ff861640e4a596977d Mon Sep 17 00:00:00 2001 From: PM-pinou <2504420230@qq.com> Date: Fri, 24 Jul 2026 13:26:07 +0800 Subject: [PATCH] refactor: extract inline JS/CSS from templates into static files - static/tianxuan/globe_embed.js: Three.js globe scene (initGlobe function) - static/tianxuan/timeline.js: LLM workflow timeline (buildTimeline, renderTimeline, escapeHtml, prettyJson, toggleStep) - static/tianxuan/markdown.js: Markdown-to-HTML converter (renderMarkdown) - static/tianxuan/cluster.css: Shared cluster page styles (pills, cards, feats, detail panel) Updated templates: - auto.html: use timeline.js, remove 140 lines of inline JS - cluster_overview.html: use cluster.css, keep layout-specific styles - cluster_detail.html: use cluster.css, keep entity-card styles --- static/tianxuan/cluster.css | 133 +++++++++++++++++++ static/tianxuan/globe_embed.js | 129 ++++++++++++++++++ static/tianxuan/markdown.js | 43 ++++++ static/tianxuan/timeline.js | 162 +++++++++++++++++++++++ templates/analysis/cluster_detail.html | 6 +- templates/analysis/cluster_overview.html | 30 +---- templates/tianxuan/auto.html | 139 +------------------ 7 files changed, 476 insertions(+), 166 deletions(-) create mode 100644 static/tianxuan/cluster.css create mode 100644 static/tianxuan/globe_embed.js create mode 100644 static/tianxuan/markdown.js create mode 100644 static/tianxuan/timeline.js diff --git a/static/tianxuan/cluster.css b/static/tianxuan/cluster.css new file mode 100644 index 0000000..51060b1 --- /dev/null +++ b/static/tianxuan/cluster.css @@ -0,0 +1,133 @@ +/** + * Shared cluster page styles. + * Extracted from templates/analysis/cluster_overview.html and cluster_detail.html + */ + +/* ── Legend pills ── */ +.legend-pills { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + padding: 6px 0; +} +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.75rem; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.15s; + white-space: nowrap; +} +.pill:hover { + opacity: 0.8; +} +.pill.active { + border-color: #333; + font-weight: 600; +} + +/* ── Cluster card ── */ +.cluster-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1rem; + background: #fff; + cursor: pointer; + transition: box-shadow 0.15s; +} +.cluster-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} +.cluster-card.selected { + border-color: #4361ee; + box-shadow: 0 0 0 2px #4361ee33; +} + +/* ── NL summary ── */ +.nl-summary { + background: #f8f9fa; + border-left: 3px solid #4361ee; + padding: 0.6rem; + margin: 0.5rem 0; + border-radius: 0 4px 4px 0; + font-size: 0.85rem; + line-height: 1.6; + color: #333; +} + +/* ── Feature table ── */ +.feat-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} +.feat-table th { + background: #f5f5f5; + padding: 0.3rem 0.5rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid #ddd; + white-space: nowrap; +} +.feat-table td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid #eee; +} +.feat-table td code { + font-size: 0.72rem; +} + +/* ── Feature scores ── */ +.feat-positive { + color: #2e7d32; +} +.feat-negative { + color: #c62828; +} + +/* ── Detail panel (overlay) ── */ +.detail-overlay { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + transform: translateY(100%); + transition: transform 0.35s cubic-bezier(.4,0,.2,1); +} +.detail-overlay.open { + transform: translateY(0); +} +.detail-content { + background: #fff; + border-radius: 16px 16px 0 0; + box-shadow: 0 -4px 24px rgba(0,0,0,0.15); + max-height: 45vh; + overflow-y: auto; + padding: 1rem 2rem 2rem; +} +.detail-handle { + width: 40px; + height: 4px; + background: #ccc; + border-radius: 2px; + margin: 0.5rem auto; + cursor: pointer; +} +.detail-handle:hover { + background: #999; +} +.detail-close { + float: right; + cursor: pointer; + font-size: 1.5rem; + color: #999; + line-height: 1; +} +.detail-close:hover { + color: #333; +} diff --git a/static/tianxuan/globe_embed.js b/static/tianxuan/globe_embed.js new file mode 100644 index 0000000..800637c --- /dev/null +++ b/static/tianxuan/globe_embed.js @@ -0,0 +1,129 @@ +/** + * Three.js 3D globe rendering for TLS flow analysis. + * Extracted from templates/tianxuan/globe_embed.html + * + * Usage: initGlobe('globeContainer', geoFlows, options) + */ +function initGlobe(containerId, geoFlows, options) { + options = options || {}; + var W = window.innerWidth, H = window.innerHeight; + + var scene = new THREE.Scene(); + var camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 1000); + camera.position.set(0, 2, 12); + + var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(W, H); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + document.getElementById(containerId).appendChild(renderer.domElement); + + // Earth + var earthGeo = new THREE.SphereGeometry(5, 64, 64); + var earthMat = new THREE.MeshPhongMaterial({ color: 0x1a3a5c, emissive: 0x0a1a2a, specular: new THREE.Color(0x333333), shininess: 5 }); + var earth = new THREE.Mesh(earthGeo, earthMat); + scene.add(earth); + + // Glow + var glow = new THREE.Mesh(new THREE.SphereGeometry(5.1, 64, 64), new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.1 })); + scene.add(glow); + + // Lights + scene.add(new THREE.AmbientLight(0x222244)); + var dl = new THREE.DirectionalLight(0xffffff, 1); + dl.position.set(5, 10, 7); + scene.add(dl); + scene.add(new THREE.DirectionalLight(0x4488ff, 0.3)); + + // Stars + var starGeo = new THREE.BufferGeometry(); + var starPos = new Float32Array(1000 * 3); + for (var i = 0; i < 1000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 200; + starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3)); + scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.15 }))); + + // Lat/lon grid + var gridMat = new THREE.LineBasicMaterial({ color: 0x6699cc, transparent: true, opacity: 0.1 }); + for (var lat = -80; lat <= 80; lat += 20) { + var pts = []; + for (var lon = 0; lon <= 360; lon += 5) { + var phi = (90 - lat) * Math.PI / 180; + var theta = (lon + 180) * Math.PI / 180; + pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta))); + } + scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat)); + } + for (var lon = 0; lon < 360; lon += 20) { + var pts = []; + for (var lat = -90; lat <= 90; lat += 5) { + var phi = (90 - lat) * Math.PI / 180; + var theta = (lon + 180) * Math.PI / 180; + pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta))); + } + scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat)); + } + + // Flow arcs + var tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 }; + var flowGroup = new THREE.Group(); + scene.add(flowGroup); + + function latLonToVec3(lat, lon, r) { + if (lat == null || lon == null) return null; + var phi = (90 - lat) * Math.PI / 180; + var theta = (lon + 180) * Math.PI / 180; + return new THREE.Vector3(-r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta)); + } + + var arcMeshes = []; + var flowData = Array.isArray(geoFlows) ? geoFlows : (typeof geoFlows === 'string' ? JSON.parse(geoFlows) : []); + flowData.forEach(function (flow) { + var from = latLonToVec3(flow.slat, flow.slon, 5); + var to = latLonToVec3(flow.dlat, flow.dlon, 5); + if (!from || !to) return; + var mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(7); + var curve = new THREE.QuadraticBezierCurve3(from, mid, to); + var color = tlsColors[flow.tls] || 0x888888; + var mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.6 }); + var mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.02, 4, false), mat); + mesh.userData = flow; + flowGroup.add(mesh); + arcMeshes.push(mesh); + }); + + // Mouse rotation + var isDragging = false, prevMouse = { x: 0, y: 0 }; + renderer.domElement.addEventListener('mousedown', function (e) { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; }); + window.addEventListener('mouseup', function () { isDragging = false; }); + window.addEventListener('mousemove', function (e) { + if (!isDragging) return; + var dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y; + earth.rotation.y += dx * 0.005; + earth.rotation.z += dy * 0.005; + flowGroup.rotation.y = earth.rotation.y; + flowGroup.rotation.z = earth.rotation.z; + prevMouse = { x: e.clientX, y: e.clientY }; + }); + + // Listen for cluster filter from parent + window.addEventListener('message', function (e) { + if (e.data && e.data.type === 'globe_filter') { + var label = e.data.cluster_label; + arcMeshes.forEach(function (mesh) { + if (label === null) { + mesh.material.opacity = 0.6; + mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888); + } else { + mesh.material.opacity = 0.06; + mesh.material.color.setHex(0x666666); + } + }); + } + }); + + // Auto-rotate + (function animate() { + requestAnimationFrame(animate); + if (!isDragging) { earth.rotation.y += 0.003; flowGroup.rotation.y = earth.rotation.y; } + renderer.render(scene, camera); + })(); +} diff --git a/static/tianxuan/markdown.js b/static/tianxuan/markdown.js new file mode 100644 index 0000000..0e290eb --- /dev/null +++ b/static/tianxuan/markdown.js @@ -0,0 +1,43 @@ +/** + * Simple Markdown-to-HTML converter. + * Extracted from templates/tianxuan/auto.html + * + * Usage: renderMarkdown(text) → HTML string + */ +function renderMarkdown(text) { + if (!text) return ''; + // Escape HTML first + var html = escapeHtml(text); + // Code blocks (```...```) — must be before inline code + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '
$2
'); + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + // Bold (**text** or __text__) + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + html = html.replace(/__([^_]+)__/g, '$1'); + // Italic (*text* or _text_) + html = html.replace(/\*([^*]+)\*/g, '$1'); + html = html.replace(/(?$1'); + // Links [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + // Headers (# ## ###) + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + html = html.replace(/^# (.+)$/gm, '

$1

'); + // Blockquotes + html = html.replace(/^>\s?(.+)$/gm, '
$1
'); + // Unordered lists + html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/()\n(?!' + m + ''; }); + // Ordered lists + html = html.replace(/^\d+\.\s+(.+)$/gm, '
  • $1
  • '); + // Horizontal rules + html = html.replace(/^---$/gm, '
    '); + // Paragraphs (double newlines → paragraphs) + html = html.replace(/\n\n/g, '

    '); + // Line breaks (single newlines → br) + html = html.replace(/\n/g, '
    '); + // Wrap in paragraph if not already wrapped + if (!html.startsWith('<')) html = '

    ' + html + '

    '; + return html; +} diff --git a/static/tianxuan/timeline.js b/static/tianxuan/timeline.js new file mode 100644 index 0000000..627b11b --- /dev/null +++ b/static/tianxuan/timeline.js @@ -0,0 +1,162 @@ +/** + * LLM workflow timeline rendering. + * Extracted from templates/tianxuan/auto.html and templates/analysis/run_detail.html + * + * Provides: + * buildTimeline(llmThinking, toolCalls) → entries[] + * renderTimeline(entries, containerId) + * escapeHtml(str) + * prettyJson(obj) + * toggleStep(el) + */ + +/** + * Escape HTML special characters. + */ +function escapeHtml(str) { + if (str == null) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * Pretty-print object as JSON string. + */ +function prettyJson(obj) { + try { return JSON.stringify(obj, null, 2); } + catch (e) { return String(obj); } +} + +/** + * Toggle a collapsible step card. + */ +function toggleStep(el) { + el.classList.toggle('open'); + var body = el.nextElementSibling; + if (body) body.classList.toggle('open'); +} + +/** + * Build timeline entries from LLM thinking text and tool calls. + * + * @param {string} llmThinking - Raw LLM thinking text with [step] markers + * @param {Array} toolCalls - Array of { step, name, input, output } + * @returns {Array} entries - Interleaved array of { step, type, text/name/input/output } + */ +function buildTimeline(llmThinking, toolCalls) { + var thinkingByStep = {}; + var thinkOrder = []; + if (llmThinking) { + var parts = llmThinking.split(/\n(?=\[\d+\])/); + for (var pi = 0; pi < parts.length; pi++) { + var m = parts[pi].match(/^\[(\d+)\]\s*(.*)/s); + if (m) { + var step = parseInt(m[1]); + if (!(step in thinkingByStep)) { + thinkOrder.push(step); + } + thinkingByStep[step] = (thinkingByStep[step] || '') + (thinkingByStep[step] ? '\n' : '') + m[2].trim(); + } + } + } + + var toolMap = {}; + (toolCalls || []).forEach(function (tc) { + var s = tc.step; + if (!toolMap[s]) toolMap[s] = []; + toolMap[s].push({ + type: 'tool', + name: tc.name, + input: tc.input, + output: tc.output, + }); + }); + + var entries = []; + var allSteps = new Set(thinkOrder.concat(Object.keys(toolMap).map(Number))); + var sortedSteps = Array.from(allSteps).sort(function (a, b) { return a - b; }); + + for (var si = 0; si < sortedSteps.length; si++) { + var step = sortedSteps[si]; + if (thinkingByStep[step] !== undefined) { + var isLastStep = step === sortedSteps[sortedSteps.length - 1]; + var hasTool = toolMap[step] && toolMap[step].length > 0; + if (isLastStep && !hasTool) { + entries.push({ step: step, type: 'answer', text: thinkingByStep[step] }); + } else { + entries.push({ step: step, type: 'thinking', text: thinkingByStep[step] }); + } + } + + if (toolMap[step]) { + for (var tj = 0; tj < toolMap[step].length; tj++) { + var te = toolMap[step][tj]; + entries.push({ step: step, type: 'tool', name: te.name, input: te.input, output: te.output, thinking: '' }); + } + } + } + + return entries; +} + +/** + * Render timeline entries into a DOM container. + * + * @param {Array} entries - From buildTimeline() + * @param {string} containerId - DOM element ID to render into + */ +function renderTimeline(entries, containerId) { + var container = document.getElementById(containerId); + if (!container || !entries || entries.length === 0) return; + + container.innerHTML = ''; + var lastStep = -1; + + for (var ei = 0; ei < entries.length; ei++) { + var entry = entries[ei]; + + if (entry.step !== lastStep) { + var header = document.createElement('div'); + header.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem 0;border-top:1px solid #eee;margin-top:0.4rem;'; + header.textContent = '\u6b65\u9aa4 ' + entry.step; + container.appendChild(header); + lastStep = entry.step; + } + + if (entry.type === 'thinking') { + var div = document.createElement('div'); + div.style.cssText = 'background:#eef2ff;padding:0.6rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:300px;overflow:auto;margin-bottom:0.3rem;border:1px solid #d0d8f0;'; + div.innerHTML = + '\ud83d\udcad \u601d\u8003' + + (typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '
    ')); + container.appendChild(div); + } else if (entry.type === 'answer') { + var div = document.createElement('div'); + div.style.cssText = 'background:#e8f5e9;padding:0.6rem 0.8rem;border-radius:6px;font-size:0.82rem;line-height:1.7;color:#333;max-height:400px;overflow:auto;margin-bottom:0.3rem;border:1px solid #c8e6c9;'; + div.innerHTML = + '\u2705 \u56de\u7b54' + + (typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '
    ')); + container.appendChild(div); + } else if (entry.type === 'tool') { + var card = document.createElement('div'); + card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;'; + var inputStr = prettyJson(entry.input); + var outputStr = prettyJson(entry.output); + var bodyInner = ''; + bodyInner += + '
    \u8f93\u5165 (INPUT):
    ' + + '
    ' + escapeHtml(inputStr) + '
    ' + + '
    \u8f93\u51fa (OUTPUT):
    ' + + '
    ' + escapeHtml(outputStr) + '
    '; + card.innerHTML = + '
    ' + + '\ud83d\udd27' + + '' + escapeHtml(entry.name || '?') + '' + + '\u25b6' + + '
    ' + + '
    ' + + bodyInner + + '
    '; + container.appendChild(card); + } + } +} diff --git a/templates/analysis/cluster_detail.html b/templates/analysis/cluster_detail.html index ccba0d8..bd63019 100644 --- a/templates/analysis/cluster_detail.html +++ b/templates/analysis/cluster_detail.html @@ -2,15 +2,11 @@ {% load static %} {% block title %}Cluster #{{ cluster.cluster_label }} — Run #{{ run.display_id }}{% endblock %} {% block content %} +
    diff --git a/templates/analysis/cluster_overview.html b/templates/analysis/cluster_overview.html index e5526b2..91c27dc 100644 --- a/templates/analysis/cluster_overview.html +++ b/templates/analysis/cluster_overview.html @@ -2,41 +2,23 @@ {% load static %} {% block title %}聚类概览 — 运行 #{{ run.display_id }}{% endblock %} {% block content %} + diff --git a/templates/tianxuan/auto.html b/templates/tianxuan/auto.html index 502fa9c..7b3e9a9 100644 --- a/templates/tianxuan/auto.html +++ b/templates/tianxuan/auto.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load static %} {% block title %}LLM 自动分析 - 天璇{% endblock %} {% block content %}