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
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
}
|
||||
@@ -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, '<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.78rem;overflow:auto;margin:0.4rem 0;"><code>$2</code></pre>');
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:#1a1a2e;color:#e0e0e0;padding:0.1rem 0.3rem;border-radius:3px;font-size:0.78rem;">$1</code>');
|
||||
// Bold (**text** or __text__)
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||
// Italic (*text* or _text_)
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/(?<!_)_{1}(?!_)([^_]+)_{1}(?!_)/g, '<em>$1</em>');
|
||||
// Links [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#4361ee;">$1</a>');
|
||||
// Headers (# ## ###)
|
||||
html = html.replace(/^### (.+)$/gm, '<h4 style="margin:0.5rem 0 0.3rem;font-size:0.95rem;">$1</h4>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h3 style="margin:0.5rem 0 0.3rem;font-size:1rem;">$1</h3>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h2 style="margin:0.5rem 0 0.3rem;font-size:1.1rem;">$1</h2>');
|
||||
// Blockquotes
|
||||
html = html.replace(/^>\s?(.+)$/gm, '<blockquote style="border-left:3px solid #4361ee;padding:0.3rem 0.6rem;margin:0.3rem 0;background:#f0f4ff;border-radius:0 4px 4px 0;">$1</blockquote>');
|
||||
// Unordered lists
|
||||
html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li style="margin:0.15rem 0;">$1</li>');
|
||||
html = html.replace(/(<li[\s\S]*?<\/li>)\n(?!<li)/g, function (m) { return '<ul style="padding-left:1.5rem;margin:0.3rem 0;">' + m + '</ul>'; });
|
||||
// Ordered lists
|
||||
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li style="margin:0.15rem 0;">$1</li>');
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid #ddd;margin:0.5rem 0;">');
|
||||
// Paragraphs (double newlines → paragraphs)
|
||||
html = html.replace(/\n\n/g, '</p><p style="margin:0.4rem 0;">');
|
||||
// Line breaks (single newlines → br)
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
// Wrap in paragraph if not already wrapped
|
||||
if (!html.startsWith('<')) html = '<p style="margin:0.4rem 0;">' + html + '</p>';
|
||||
return html;
|
||||
}
|
||||
@@ -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, '>').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 =
|
||||
'<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.3rem;">\ud83d\udcad \u601d\u8003</span>' +
|
||||
(typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '<br>'));
|
||||
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 =
|
||||
'<span style="font-weight:600;color:#2e7d32;font-size:0.75rem;display:block;margin-bottom:0.3rem;">\u2705 \u56de\u7b54</span>' +
|
||||
(typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '<br>'));
|
||||
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 +=
|
||||
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">\u8f93\u5165 (INPUT):</div>' +
|
||||
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
|
||||
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">\u8f93\u51fa (OUTPUT):</div>' +
|
||||
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>';
|
||||
card.innerHTML =
|
||||
'<div class="tool-call-header" onclick="toggleStep(this)" style="padding:0.4rem 0.8rem;">' +
|
||||
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">\ud83d\udd27</span>' +
|
||||
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(entry.name || '?') + '</span></span>' +
|
||||
'<span class="arrow">\u25b6</span>' +
|
||||
'</div>' +
|
||||
'<div class="tool-call-body" style="padding:0.5rem 0.8rem;background:#fafbfc;">' +
|
||||
bodyInner +
|
||||
'</div>';
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,11 @@
|
||||
{% load static %}
|
||||
{% block title %}Cluster #{{ cluster.cluster_label }} — Run #{{ run.display_id }}{% endblock %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{% static 'tianxuan/cluster.css' %}">
|
||||
<style>
|
||||
.entity-card { border:1px solid #e0e0e0; border-radius:6px; padding:0.6rem; margin-bottom:0.4rem; background:#fafafa; }
|
||||
.entity-card .val { font-weight:600; font-size:0.85rem; }
|
||||
.entity-card .feat { font-size:0.75rem; color:#666; display:inline-block; margin-right:0.5rem; }
|
||||
.feat-positive { color:#2e7d32; }
|
||||
.feat-negative { color:#c62828; }
|
||||
.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; }
|
||||
.feat-table td { padding:0.25rem 0.5rem; border-bottom:1px solid #eee; }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -2,41 +2,23 @@
|
||||
{% load static %}
|
||||
{% block title %}聚类概览 — 运行 #{{ run.display_id }}{% endblock %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{% static 'tianxuan/cluster.css' %}">
|
||||
<style>
|
||||
/* ── 布局 ── */
|
||||
.main-layout { display:flex; gap:0; transition:all 0.3s; }
|
||||
/* ── Layout-specific (unique to cluster_overview) ── */
|
||||
.main-layout { display:flex; gap:0; transition:all 0.3s; position:relative; }
|
||||
.left-panel { flex:1; min-width:0; transition:flex 0.3s; }
|
||||
.left-panel.globe-open { flex:0 0 55%; }
|
||||
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; border-left:1px solid #e0e0e0; position:relative; }
|
||||
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; border-left:1px solid #e0e0e0; }
|
||||
.right-panel.globe-open { flex:0 0 45%; }
|
||||
.globe-toggle-handle { position:absolute; left:-24px; top:50%; transform:translateY(-50%); width:24px; height:48px; background:#1a1a2e; color:#fff; border:none; border-radius:6px 0 0 6px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px; z-index:10; transition:background 0.15s; }
|
||||
.globe-toggle-handle { position:absolute; z-index:10; top:50%; left:calc(100% - 24px); transform:translateY(-50%); width:24px; height:48px; background:#1a1a2e; color:#fff; border:none; border-radius:6px 0 0 6px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px; transition:left 0.3s, background 0.15s; }
|
||||
.globe-toggle-handle:hover { background:#4361ee; }
|
||||
.globe-toggle-handle.open { left:0; border-radius:0; }
|
||||
.globe-toggle-handle.open { left:calc(55% - 24px); border-radius:6px 0 0 6px; }
|
||||
/* ── Scatter ── */
|
||||
.scatter-wrapper { position:relative; cursor:crosshair; }
|
||||
.scatter-wrapper canvas { width:100%; height:400px; display:block; }
|
||||
#scatter3D { width:100%; height:450px; }
|
||||
/* ── Detail Panel ── */
|
||||
.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; }
|
||||
/* ── Globe ── */
|
||||
#globeContainer { width:100%; height:calc(100vh - 120px); position:sticky; top:0; }
|
||||
/* ── 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 { 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 { 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; }
|
||||
.feat-positive { color:#2e7d32; }
|
||||
.feat-negative { color:#c62828; }
|
||||
</style>
|
||||
<script src="{% static 'tianxuan/three.min.js' %}"></script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% block title %}LLM 自动分析 - 天璇{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
@@ -200,6 +201,7 @@
|
||||
请从上传页面启动LLM分析
|
||||
</div>
|
||||
|
||||
<script src="{% static 'tianxuan/timeline.js' %}"></script>
|
||||
<script>
|
||||
const DATASETS_WITH_STATUS = {{ datasets_with_status_json|safe }};
|
||||
let activeFilter = 'all';
|
||||
@@ -497,143 +499,6 @@ buildDatasetTable('all');
|
||||
// ── Timeline rendering ──
|
||||
let statusPoll = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function prettyJson(obj) {
|
||||
try { return JSON.stringify(obj, null, 2); }
|
||||
catch(e) { return String(obj); }
|
||||
}
|
||||
|
||||
function toggleStep(el) {
|
||||
el.classList.toggle('open');
|
||||
const body = el.nextElementSibling;
|
||||
if (body) body.classList.toggle('open');
|
||||
}
|
||||
|
||||
function buildTimeline(llmThinking, toolCalls) {
|
||||
// Parse llm_thinking into per-step segments
|
||||
const thinkingByStep = {};
|
||||
const thinkOrder = []; // track step order for interleaving
|
||||
if (llmThinking) {
|
||||
const parts = llmThinking.split(/\n(?=\[\d+\])/);
|
||||
for (const part of parts) {
|
||||
const m = part.match(/^\[(\d+)\]\s*(.*)/s);
|
||||
if (m) {
|
||||
const step = parseInt(m[1]);
|
||||
if (!(step in thinkingByStep)) {
|
||||
thinkOrder.push(step);
|
||||
}
|
||||
thinkingByStep[step] = (thinkingByStep[step] || '') + (thinkingByStep[step] ? '\n' : '') + m[2].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build entries: interleave thinking + tool per step
|
||||
const toolMap = {}; // step -> tool entries
|
||||
(toolCalls || []).forEach(tc => {
|
||||
const s = tc.step;
|
||||
if (!toolMap[s]) toolMap[s] = [];
|
||||
toolMap[s].push({
|
||||
type: 'tool',
|
||||
name: tc.name,
|
||||
input: tc.input,
|
||||
output: tc.output,
|
||||
});
|
||||
});
|
||||
|
||||
const entries = [];
|
||||
// Collect all step numbers that appear in either thinking or tool_calls
|
||||
const allSteps = new Set([...thinkOrder, ...Object.keys(toolMap).map(Number)]);
|
||||
const sortedSteps = Array.from(allSteps).sort((a, b) => a - b);
|
||||
|
||||
for (const step of sortedSteps) {
|
||||
// Thinking first (独立条目)
|
||||
if (thinkingByStep[step] !== undefined) {
|
||||
// Last step with only thinking and no tool call = 回答
|
||||
const isLastStep = step === sortedSteps[sortedSteps.length - 1];
|
||||
const hasTool = toolMap[step] && toolMap[step].length > 0;
|
||||
if (isLastStep && !hasTool) {
|
||||
entries.push({ step, type: 'answer', text: thinkingByStep[step] });
|
||||
} else {
|
||||
entries.push({ step, type: 'thinking', text: thinkingByStep[step] });
|
||||
}
|
||||
}
|
||||
|
||||
// Then tool entries for this step
|
||||
if (toolMap[step]) {
|
||||
for (const te of toolMap[step]) {
|
||||
// Attach thinking to first tool entry only if not already shown separately
|
||||
// (No, we already show thinking separately above)
|
||||
entries.push({ step, ...te, thinking: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function renderTimeline(entries) {
|
||||
const container = document.getElementById('timeline');
|
||||
const panel = document.getElementById('timelinePanel');
|
||||
if (!entries || entries.length === 0) return;
|
||||
|
||||
panel.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
let lastStep = -1;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.step !== lastStep) {
|
||||
const 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 = '步骤 ' + entry.step;
|
||||
container.appendChild(header);
|
||||
lastStep = entry.step;
|
||||
}
|
||||
|
||||
if (entry.type === 'thinking') {
|
||||
// 思考条目 — 浅蓝背景文本块
|
||||
const 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:200px;overflow:auto;margin-bottom:0.3rem;white-space:pre-wrap;border:1px solid #d0d8f0;';
|
||||
div.innerHTML =
|
||||
'<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.3rem;">💭 思考</span>' +
|
||||
escapeHtml(entry.text);
|
||||
container.appendChild(div);
|
||||
} else if (entry.type === 'answer') {
|
||||
// 回答条目 — 绿色背景
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'background:#e8f5e9;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;white-space:pre-wrap;border:1px solid #c8e6c9;';
|
||||
div.innerHTML =
|
||||
'<span style="font-weight:600;color:#2e7d32;font-size:0.75rem;display:block;margin-bottom:0.3rem;">✅ 回答</span>' +
|
||||
escapeHtml(entry.text);
|
||||
container.appendChild(div);
|
||||
} else if (entry.type === 'tool') {
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
|
||||
const inputStr = prettyJson(entry.input);
|
||||
const outputStr = prettyJson(entry.output);
|
||||
let bodyInner = '';
|
||||
bodyInner +=
|
||||
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输入 (INPUT):</div>' +
|
||||
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
|
||||
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出 (OUTPUT):</div>' +
|
||||
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>';
|
||||
card.innerHTML =
|
||||
'<div class="tool-call-header" onclick="toggleStep(this)" style="padding:0.4rem 0.8rem;">' +
|
||||
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">🔧</span>' +
|
||||
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(entry.name || '?') + '</span></span>' +
|
||||
'<span class="arrow">▶</span>' +
|
||||
'</div>' +
|
||||
'<div class="tool-call-body" style="padding:0.5rem 0.8rem;background:#fafbfc;">' +
|
||||
bodyInner +
|
||||
'</div>';
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startAuto() {
|
||||
// Read run_id from radio buttons (primary method)
|
||||
const radio = document.querySelector('input[name="auto_run_id"]:checked');
|
||||
|
||||
Reference in New Issue
Block a user