diff --git a/analysis/management/commands/run_pipeline.py b/analysis/management/commands/run_pipeline.py index d992411..efb39ba 100644 --- a/analysis/management/commands/run_pipeline.py +++ b/analysis/management/commands/run_pipeline.py @@ -67,9 +67,9 @@ class Command(BaseCommand): _self.stdout.write(f' → 聚类特征: {feature_cols}') run.total_flows = row_count run.save(update_fields=['total_flows']) - from analysis.views import _run_clustering_pipeline - _run_clustering_pipeline( - run=run, store=store, ds_id=ds_id, + from analysis.services.clustering import run_clustering_pipeline + run_clustering_pipeline( + run=run, store=store, entity_ds_id=ds_id, feature_columns=feature_cols, algorithm=algo, min_cluster_size=5, run_umap=True, diff --git a/analysis/services/__init__.py b/analysis/services/__init__.py new file mode 100644 index 0000000..c2b9e24 --- /dev/null +++ b/analysis/services/__init__.py @@ -0,0 +1,2 @@ +"""Analysis services package — pure business logic, no request/response/rendering.""" +from .clustering import run_clustering_pipeline, compute_umap_embedding, save_entity_profiles diff --git a/analysis/services/clustering.py b/analysis/services/clustering.py new file mode 100644 index 0000000..75530fb --- /dev/null +++ b/analysis/services/clustering.py @@ -0,0 +1,361 @@ +"""Clustering service layer: pure business logic for the clustering pipeline. + +Extracted from analysis/views/clustering.py — no request/response, no rendering. +""" +import asyncio +import traceback +import logging +import warnings + +import polars as pl +import numpy as np + +from analysis.constants import RANDOM_SEED, UMAP_BATCH_SIZE, UMAP_TRAIN_SAMPLE, NUMERIC_DTYPES + +logger = logging.getLogger(__name__) + + +def run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorithm, + min_cluster_size, run_umap=True, head=None): + """Pure business logic — no request/response, no rendering. + + Unified clustering pipeline: clustering → feature extraction → UMAP → ORM save. + + Args: + run: AnalysisRun ORM object. + store: SessionStore instance. + entity_ds_id: dataset ID in SessionStore pointing to entity-aggregated data. + feature_columns: list of feature column names (None = auto-detect). + algorithm: 'hdbscan' or 'kmeans'. + min_cluster_size: int for HDBSCAN. + run_umap: bool, whether to compute and save UMAP-2D embeddings. + head: optional int, downsample to at most this many rows (min 100). + """ + warnings.filterwarnings('ignore', category=RuntimeWarning, module='sklearn') + + try: + from analysis.tool_registry import _handle_run_clustering, _handle_extract_features + + entry = store.get_dataset(entity_ds_id) + if entry is None: + run.status = 'failed' + run.error_message = 'Entity dataset not found in session store' + run.save(update_fields=['status', 'error_message']) + return + + schema = entry.get('schema', {}) + + # ── Downsampling ── + if head is not None and head > 0: + lf = entry['lazyframe'] + try: + row_count = lf.select(pl.len()).collect(streaming=True).item() + except Exception: + logger.error("unknown failed: {}".format(traceback.format_exc())) + row_count = 0 + + if row_count > head: + head = max(head, 100) # ensure minimum rows for clustering + run.progress_msg = f'正在采样 {head}/{row_count} 行...' + run.save(update_fields=['progress_msg']) + lf = lf.head(head) + store.store_dataset( + entity_ds_id, lf, + schema=schema, + metadata=entry.get('metadata', {}), + ) + entry = store.get_dataset(entity_ds_id) # refresh + row_count = head + + # Resolve feature columns — use actual LazyFrame dtypes (not stored schema dict + # which may be stale after Utf8 coercion in _background_process). + numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64, + pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64, + pl.Float32, pl.Float64) + + lf = entry['lazyframe'] + live_schema = lf.collect_schema() + live_names = live_schema.names() + live_dtypes = list(live_schema.dtypes()) + + if feature_columns: + feature_cols = [ + c for c in feature_columns + if c in live_names and live_dtypes[live_names.index(c)] in numeric_types + ] + else: + feature_cols = [ + live_names[i] for i, dt in enumerate(live_dtypes) + if dt in numeric_types and not live_names[i].startswith('_') + ][:10] + + # ── Step 1: Clustering ──────────────────────────────────────────────── + # If no numeric columns detected, pass empty list to let + # _handle_run_clustering apply its Utf8→Float64 coercion fallback. + run.status = 'clustering' + run.progress_pct = 70 + run.progress_msg = '正在进行聚类分析...' + run.save(update_fields=['status', 'progress_pct', 'progress_msg']) + + result = asyncio.run(_handle_run_clustering( + dataset_id=entity_ds_id, + cluster_columns=feature_cols, + algorithm=algorithm, + params={'min_cluster_size': min_cluster_size}, + random_state=RANDOM_SEED, + )) + + if 'error' in result: + err = result['error'] + if 'numeric' in err.lower(): + available = [ + f"{k}({v})" for k, v in schema.items() + if v.split('(')[0].strip() in numeric_types and not k.startswith('_') + ] + err += f"。可用数值列: {available}" + raise Exception(err) + + cluster_id = result.get('cluster_result_id', '') + run.cluster_count = result.get('n_clusters', 0) + run.save(update_fields=['cluster_count']) + + # ── Step 2: Feature extraction ────────────────────────────────────── + run.status = 'extracting' + run.progress_pct = 90 + run.progress_msg = '正在提取聚类特征...' + run.save(update_fields=['status', 'progress_pct', 'progress_msg']) + + feat_result = asyncio.run(_handle_extract_features( + dataset_id=entity_ds_id, + cluster_result_id=cluster_id, + top_k=10, method='zscore', save_to_db=True, + run_id=run.id, + )) + + if 'error' in feat_result: + raise Exception(feat_result['error']) + + # ── Step 2b: Cluster SVD feature extraction ────────────────────── + run.progress_msg = '正在进行聚类SVD特征提取...' + run.save(update_fields=['progress_msg']) + try: + from analysis.distance import cluster_svd_extract + lf = entry['lazyframe'] + cluster_entry = store.get_cluster_result(cluster_id) + labels_list = cluster_entry.get('labels', []) if cluster_entry else [] + + if labels_list and feature_cols: + svd_results = cluster_svd_extract(lf, labels_list, feature_cols) + + # Persist SVD features to ClusterFeature (method='cluster_svd') + from analysis.models import ClusterFeature as CF + from analysis.models import ClusterResult as CR + for label, svd_info in svd_results.items(): + cr = CR.objects.filter(run=run, cluster_label=label).first() + if cr is None: + continue + # Delete old zscore entries for this cluster, replace with SVD + CF.objects.filter(cluster=cr, distinguishing_method='zscore').delete() + for feat_name in svd_info.get('features', [])[:10]: + idx = svd_info['features'].index(feat_name) if feat_name in svd_info['features'] else -1 + strength = (svd_info.get('feature_strength', [])[idx] + if idx >= 0 and idx < len(svd_info.get('feature_strength', [])) + else None) + CF.objects.get_or_create( + cluster=cr, + feature_name=feat_name, + defaults={ + 'mean': None, 'std': None, 'median': None, + 'p25': None, 'p75': None, 'missing_rate': None, + 'distinguishing_score': float(strength) if strength is not None else None, + 'distinguishing_method': 'cluster_svd', + }, + ) + except Exception as svd_err: + logger.warning(f'Cluster SVD extraction skipped: {svd_err}') + + # ── Step 3: UMAP-3D embedding (2D fallback) ─────────────────────── + if run_umap: + try: + lf = entry['lazyframe'] + # UMAP: sample max 10K for training, batch-transform rest in 1K batches + MAX_UMAP_TRAIN = UMAP_TRAIN_SAMPLE + BATCH_SIZE = UMAP_BATCH_SIZE + try: + n_total = lf.select(pl.len()).collect(streaming=True).item() + except Exception: + logger.error("unknown failed: {}".format(traceback.format_exc())) + n_total = 0 + from sklearn.preprocessing import StandardScaler + import umap + + live_schema = lf.collect_schema() + num_cols = [name for name, dt in zip(live_schema.names(), live_schema.dtypes()) + if dt in (pl.Int8, pl.Int16, pl.Int32, pl.Int64, + pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64, + pl.Float32, pl.Float64) and not name.startswith('_')] + + if len(num_cols) >= 2: + if n_total > MAX_UMAP_TRAIN: + run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...' + run.save(update_fields=['progress_msg']) + df_sample = lf.sample(n=MAX_UMAP_TRAIN, seed=RANDOM_SEED).collect(streaming=True) + df_umap = lf.collect(streaming=True) + else: + df_umap = lf.collect(streaming=True) + + coords, umap_components = compute_umap_embedding( + df_umap, num_cols, n_total, + max_train=MAX_UMAP_TRAIN, + batch_size=BATCH_SIZE, + seed=RANDOM_SEED, + ) + + cluster_entry = store.get_cluster_result(cluster_id) + labels_list = cluster_entry.get('labels', []) if cluster_entry else [] + + save_entity_profiles(run, df_umap, labels_list, coords, umap_components) + except Exception as umap_err: + logger.warning(f'UMAP embedding skipped: {umap_err}') + + # Fallback: if entity_count not set (UMAP skipped), derive from cluster sizes + if run.entity_count is None: + from django.db.models import Sum + total = run.clusters.aggregate(total=Sum('size'))['total'] or 0 + run.entity_count = total + run.save(update_fields=['entity_count']) + + # ── Done ── + run.status = 'completed' + run.progress_pct = 100 + run.progress_msg = '分析完成' + run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'entity_count']) + + except Exception: + tb = traceback.format_exc() + logger.error(tb) + try: + run.status = 'failed' + run.error_message = tb + run.progress_msg = f'失败: {tb}' + run.run_log += f'\n[ERROR] {tb}' + run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log']) + except Exception as e: + logger.warning('save failed after pipeline error: %s', e) + + +def compute_umap_embedding(df, num_cols, n_total, max_train, batch_size, seed): + """UMAP 3D→2D fallback, returns (coords, umap_components). + + Args: + df: Collected Polars DataFrame with entity-level data. + num_cols: list of numeric column names to use for UMAP. + n_total: total row count. + max_train: max rows for UMAP training sample. + batch_size: batch size for transform when dataset exceeds max_train. + seed: random seed. + + Returns: + (coords, umap_components): numpy array of shape (n_total, umap_components), + and umap_components is 2 or 3. + """ + from sklearn.preprocessing import StandardScaler + import umap + + if len(df) <= 2 or len(num_cols) < 2: + return None, 0 + + if n_total > max_train: + df_sample = df.sample(n=max_train, seed=seed) + else: + df_sample = df + + umap_components = 3 # try 3D first + coords = None + + for attempt_n in (3, 2): + try: + if n_total > max_train: + # Train UMAP on sample, batch-transform full dataset + mat_sample = df_sample.select(num_cols).to_numpy() + scaler = StandardScaler().fit(mat_sample) + mat_sample_scaled = np.nan_to_num( + scaler.transform(mat_sample), nan=0.0) + reducer = umap.UMAP(n_components=attempt_n, + random_state=seed, + n_neighbors=15, min_dist=0.1, + metric='euclidean') + reducer.fit(mat_sample_scaled) + # Batch transform in batch_size-row batches + coords_list = [] + for start in range(0, len(df), batch_size): + end = min(start + batch_size, len(df)) + mat_batch = scaler.transform( + df[start:end].select(num_cols).to_numpy()) + mat_batch = np.nan_to_num(mat_batch, nan=0.0) + coords_list.append(reducer.transform(mat_batch)) + coords = np.vstack(coords_list) + else: + mat = np.nan_to_num(StandardScaler().fit_transform( + df.select(num_cols).to_numpy()), nan=0.0) + reducer = umap.UMAP(n_components=attempt_n, + random_state=seed, + n_neighbors=15, min_dist=0.1, + metric='euclidean') + coords = reducer.fit_transform(mat) + umap_components = attempt_n + break + except Exception as e_umap: + if attempt_n == 2: + raise + logger.warning(f'UMAP 3D failed ({e_umap}), falling back to 2D') + + if coords is None: + raise Exception('UMAP produced no output') + + return coords, umap_components + + +def save_entity_profiles(run, df_umap, labels_list, coords, umap_components): + """Persist per-row cluster labels + UMAP coords to EntityProfile. + + Args: + run: AnalysisRun ORM object. + df_umap: Collected Polars DataFrame (used for entity column detection + row iteration). + labels_list: list of cluster labels per row. + coords: numpy array of UMAP coordinates (must not be None). + umap_components: 2 or 3. + """ + from analysis.models import EntityProfile as EP + from analysis.models import ClusterResult as CR + + # Determine entity column (first non-numeric, non-underscore column) + df_num_cols = [c for c, dt in zip(df_umap.columns, df_umap.dtypes) + if dt in NUMERIC_DTYPES] + non_num = [c for c in df_umap.columns + if c not in df_num_cols and not c.startswith('_')] + ent_col = non_num[0] if non_num else df_umap.columns[0] + + profiles = [] + cr_cache = {} + for i, row in enumerate(df_umap.iter_rows(named=True)): + ev = str(row.get(ent_col, '')) or 'entity' + ev = f'{ev}_{i}' + lbl = labels_list[i] if i < len(labels_list) else -1 + cache_key = f'{run.id}_{lbl}' + if cache_key not in cr_cache: + cr_cache[cache_key] = CR.objects.filter( + run=run, cluster_label=lbl).first() + has_z = umap_components >= 3 and coords.shape[1] >= 3 + profiles.append(EP( + entity_value=ev, run=run, cluster_label=lbl, + cluster=cr_cache[cache_key], + embedding_x=float(coords[i, 0]) if i < len(coords) else None, + embedding_y=float(coords[i, 1]) if i < len(coords) else None, + embedding_z=float(coords[i, 2]) if has_z + and i < len(coords) else None, + )) + if profiles: + EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000) + run.entity_count = len(profiles) + run.save(update_fields=['entity_count']) diff --git a/analysis/views/auto.py b/analysis/views/auto.py index 527a77d..50e20d6 100644 --- a/analysis/views/auto.py +++ b/analysis/views/auto.py @@ -12,7 +12,7 @@ from django.views.decorators.csrf import csrf_exempt from config import get_config from analysis.models import AnalysisRun from .pipeline import _run_pipeline_worker -from .clustering import _run_clustering_pipeline +from analysis.services.clustering import run_clustering_pipeline from .helpers import _PLANS_DIR, _add_to_auto_index logger = logging.getLogger(__name__) @@ -271,7 +271,7 @@ def run_llm_analysis_view(request): break if entity_ds_id: - _run_clustering_pipeline( + run_clustering_pipeline( run=run, store=store, ds_id=entity_ds_id, diff --git a/analysis/views/clustering.py b/analysis/views/clustering.py index ec7ffd0..3dcd195 100644 --- a/analysis/views/clustering.py +++ b/analysis/views/clustering.py @@ -227,6 +227,7 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm, Operates directly on raw data rows (no entity aggregation). Per-row results stored in EntityProfile with row index as identifier. +<<<<<<< HEAD Args: run: AnalysisRun ORM object. store: SessionStore instance. @@ -525,3 +526,9 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm, run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log']) except Exception as e: logger.warning('save failed after pipeline error: %s', e) + """ + Delegates to the service layer. + """ + from analysis.services.clustering import run_clustering_pipeline + run_clustering_pipeline(run, store, feature_columns, algorithm, + min_cluster_size, run_umap=run_umap, head=head) diff --git a/analysis/views/manual.py b/analysis/views/manual.py index 1ef10eb..bbfd000 100644 --- a/analysis/views/manual.py +++ b/analysis/views/manual.py @@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt from analysis.models import AnalysisRun from .pipeline import _run_pipeline_worker -from .clustering import _run_clustering_pipeline +from analysis.services.clustering import run_clustering_pipeline logger = logging.getLogger(__name__) @@ -23,9 +23,9 @@ def manual_page(request): # Get all tools metadata tools = get_tools_meta() - core_names = {'profile_data', 'run_clustering', 'extract_features', - 'filter_data', 'preprocess_data', - 'detect_anomalies', 'visualize_anomalies'} + core_names = {'profile_data', 'build_entity_profiles', 'compute_scores', + 'run_clustering', 'extract_features', 'detect_anomalies', + 'visualize_anomalies'} diag_names = {'validate_data', 'explore_distributions', 'find_outliers', 'diagnose_clustering', 'compare_datasets', 'export_debug_sample', 'repair_schema'} @@ -164,8 +164,8 @@ def manual_run_analysis(request): ds_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'upload_{pk}' # Use the unified clustering pipeline (clustering → extraction → UMAP) - _run_clustering_pipeline( - run=run, store=store, ds_id=ds_id, + run_clustering_pipeline( + run=run, store=store, entity_ds_id=ds_id, feature_columns=feature_columns, algorithm=algorithm, min_cluster_size=min_cluster_size, run_umap=True, head=head, 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'); + // Unordered lists + html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '
');
+ // 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, '' + escapeHtml(inputStr) + '' +
+ '' + escapeHtml(outputStr) + '';
+ card.innerHTML =
+ '