#!/usr/bin/env python3
"""
WorkBuddy Monitor - 上下文内容与Token用量监控面板
================================================
监控 WorkBuddy 的 trace 文件，提供 token 使用量实时统计与可视化。

数据来源: C:\\Users\\Starry\\.workbuddy\\traces\\*.json
"""

import json
import os
import time
import threading
import sqlite3
import glob
import logging
from datetime import datetime, timedelta
from pathlib import Path
from flask import Flask, jsonify, render_template_string, request

# ========== LLM Proxy (方案B) ==========
import importlib
llm_proxy = None
_proxy_thread = None

# ========== Configuration ==========
TRACE_DIR = os.path.expanduser(r"~\.workbuddy\traces")
# 使用用户目录下的持久化路径，确保 PyInstaller 打包后数据库持久化
_MONITOR_DIR = os.path.expanduser(r"~\.workbuddy-monitor")
os.makedirs(_MONITOR_DIR, exist_ok=True)
DB_PATH = os.path.join(_MONITOR_DIR, "monitor_data.db")
HOST = "127.0.0.1"
PORT = 5910
POLL_INTERVAL = 5  # seconds between trace file scans

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger("wb-monitor")

app = Flask(__name__)

# ========== Database ==========

def init_db():
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute("""
        CREATE TABLE IF NOT EXISTS traces (
            trace_id TEXT PRIMARY KEY,
            session_id TEXT,
            agent_name TEXT,
            model_name TEXT,
            status TEXT,
            started_at REAL,
            ended_at REAL,
            duration INTEGER,
            span_count INTEGER,
            total_tokens INTEGER,
            total_input_tokens INTEGER,
            total_output_tokens INTEGER,
            total_cached_tokens INTEGER,
            last_call_input_tokens INTEGER,
            call_count INTEGER,
            file_path TEXT
        )
    """)
    c.execute("""
        CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id)
    """)
    c.execute("""
        CREATE INDEX IF NOT EXISTS idx_traces_started ON traces(started_at)
    """)
    c.execute("""
        CREATE TABLE IF NOT EXISTS snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp REAL,
            total_tokens_all INTEGER,
            total_input_all INTEGER,
            total_output_all INTEGER,
            total_cached_all INTEGER,
            total_calls_all INTEGER,
            trace_count INTEGER
        )
    """)
    conn.commit()
    conn.close()


def parse_trace_file(filepath):
    """Parse a single trace JSON file and return a dict of values."""
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            data = json.load(f)
    except (json.JSONDecodeError, IOError) as e:
        log.warning("Failed to parse %s: %s", filepath, e)
        return None

    trace = data.get("trace", data)
    trace_id = trace.get("traceId", os.path.basename(filepath).replace(".json", ""))
    session_id = trace.get("sessionId", "")
    agent_name = trace.get("agentName", "")
    status = trace.get("status", "")
    started_at = trace.get("startedAt", 0)
    ended_at = trace.get("endedAt", 0)
    duration = trace.get("duration", 0)
    span_count = trace.get("spanCount", 0)
    total_tokens = trace.get("totalTokens", 0)

    mi = trace.get("modelInfo", {})
    if not mi:
        return None

    model_name = ", ".join(mi.get("models", ["unknown"]))
    total_input = mi.get("totalInputTokens", 0)
    total_output = mi.get("totalOutputTokens", 0)
    total_cached = mi.get("totalCachedTokens", 0)
    last_call_input = mi.get("lastCallInputTokens", 0)
    call_count = mi.get("callCount", 0)

    # Convert timestamps (handle number, string ISO 8601, or None)
    def ts_to_seconds(val):
        if val is None:
            return 0
        # Try parsing as ISO 8601 string first
        if isinstance(val, str):
            try:
                from datetime import datetime
                dt = datetime.fromisoformat(val.replace('Z', '+00:00'))
                return dt.timestamp()
            except (ValueError, TypeError):
                return 0
        # Try numeric
        try:
            v = float(val)
            return v / 1000 if v > 1e10 else v
        except (TypeError, ValueError):
            return 0

    return {
        "trace_id": trace_id,
        "session_id": session_id,
        "agent_name": agent_name,
        "model_name": model_name,
        "status": status,
        "started_at": ts_to_seconds(started_at),
        "ended_at": ts_to_seconds(ended_at),
        "duration": duration if isinstance(duration, (int, float)) and duration < 86400000 else 0,
        "span_count": span_count if isinstance(span_count, (int, float)) else 0,
        "total_tokens": total_tokens if isinstance(total_tokens, (int, float)) and total_tokens > 0 else (total_input + total_output),
        "total_input_tokens": total_input,
        "total_output_tokens": total_output,
        "total_cached_tokens": total_cached,
        "last_call_input_tokens": last_call_input,
        "call_count": call_count,
        "file_path": filepath,
    }


def upsert_trace(conn, record):
    if record is None:
        return False
    c = conn.cursor()
    c.execute("""
        INSERT OR REPLACE INTO traces (
            trace_id, session_id, agent_name, model_name, status,
            started_at, ended_at, duration, span_count, total_tokens,
            total_input_tokens, total_output_tokens, total_cached_tokens,
            last_call_input_tokens, call_count, file_path
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        record["trace_id"], record["session_id"], record["agent_name"],
        record["model_name"], record["status"], record["started_at"],
        record["ended_at"], record["duration"], record["span_count"],
        record["total_tokens"], record["total_input_tokens"],
        record["total_output_tokens"], record["total_cached_tokens"],
        record["last_call_input_tokens"], record["call_count"],
        record["file_path"],
    ))
    return c.rowcount > 0


def scan_all_traces(conn):
    """Scan all trace files in TRACE_DIR and upsert new/changed ones."""
    if not os.path.isdir(TRACE_DIR):
        log.warning("Trace directory not found: %s", TRACE_DIR)
        return 0

    pid_dirs = sorted(os.listdir(TRACE_DIR))
    total = 0
    for pid in pid_dirs:
        pid_dir = os.path.join(TRACE_DIR, pid)
        if not os.path.isdir(pid_dir):
            continue
        for fname in os.listdir(pid_dir):
            if fname.startswith("trace_") and fname.endswith(".json"):
                fpath = os.path.join(pid_dir, fname)
                record = parse_trace_file(fpath)
                if upsert_trace(conn, record):
                    total += 1
    return total


def take_snapshot(conn):
    """Record an aggregate snapshot for time-series charts."""
    c = conn.cursor()
    c.execute("""
        SELECT COALESCE(SUM(total_input_tokens), 0),
               COALESCE(SUM(total_output_tokens), 0),
               COALESCE(SUM(total_cached_tokens), 0),
               COALESCE(SUM(total_tokens), 0),
               COALESCE(SUM(call_count), 0),
               COUNT(*)
        FROM traces
    """)
    row = c.fetchone()
    now = time.time()
    c.execute("""
        INSERT INTO snapshots (timestamp, total_input_all, total_output_all,
                               total_cached_all, total_tokens_all,
                               total_calls_all, trace_count)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    """, (now, row[0], row[1], row[2], row[3], row[4], row[5]))
    conn.commit()


# ========== Background Scanner ==========

class TraceWatcher(threading.Thread):
    def __init__(self):
        super().__init__(daemon=True)
        self._stop_event = threading.Event()
        self._known_files = set()

    def run(self):
        log.info("Trace watcher started (poll interval: %ds)", POLL_INTERVAL)
        conn = sqlite3.connect(DB_PATH)

        # Initial full scan
        count = scan_all_traces(conn)
        conn.commit()
        log.info("Initial scan: %d traces loaded", count)

        # Get known files
        c = conn.cursor()
        c.execute("SELECT file_path FROM traces")
        self._known_files = {row[0] for row in c.fetchall()}

        take_snapshot(conn)
        conn.close()

        while not self._stop_event.is_set():
            time.sleep(POLL_INTERVAL)
            try:
                conn = sqlite3.connect(DB_PATH)
                new_count = 0
                pid_dirs = sorted(os.listdir(TRACE_DIR))
                for pid in pid_dirs:
                    pid_dir = os.path.join(TRACE_DIR, pid)
                    if not os.path.isdir(pid_dir):
                        continue
                    for fname in os.listdir(pid_dir):
                        if fname.startswith("trace_") and fname.endswith(".json"):
                            fpath = os.path.join(pid_dir, fname)
                            if fpath not in self._known_files:
                                record = parse_trace_file(fpath)
                                if upsert_trace(conn, record):
                                    self._known_files.add(fpath)
                                    new_count += 1
                if new_count > 0:
                    conn.commit()
                    take_snapshot(conn)
                    log.info("New traces: %d (total: %d)", new_count, len(self._known_files))
                conn.close()
            except Exception as e:
                log.error("Scan error: %s", e)

    def stop(self):
        self._stop_event.set()


# ========== Flask Routes ==========

@app.route("/")
def index():
    return render_template_string(HTML_TEMPLATE)


@app.route("/api/overview")
def api_overview():
    """Current aggregate overview."""
    raw_limit = request.args.get("limit", "20")
    show_all = raw_limit.lower() in ("all", "0", "-1")
    if show_all:
        limit = None  # no LIMIT
        display_limit = "全部"
    else:
        try:
            limit = max(1, min(int(raw_limit), 200))
        except ValueError:
            limit = 20
        display_limit = str(limit)

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()

    c.execute("""
        SELECT COALESCE(SUM(total_input_tokens), 0) as total_input,
               COALESCE(SUM(total_output_tokens), 0) as total_output,
               COALESCE(SUM(total_cached_tokens), 0) as total_cached,
               COALESCE(SUM(total_tokens), 0) as total_tokens_all,
               COALESCE(SUM(call_count), 0) as total_calls,
               COUNT(*) as trace_count,
               COUNT(DISTINCT session_id) as session_count
        FROM traces
    """)
    overview = dict(c.fetchone())

    # Latest trace
    c.execute("SELECT * FROM traces ORDER BY started_at DESC LIMIT 1")
    latest_row = c.fetchone()
    if latest_row:
        latest = dict(latest_row)
        latest["started_at_str"] = datetime.fromtimestamp(latest["started_at"]).strftime("%m-%d %H:%M:%S")
    else:
        latest = None

    # Per-model breakdown
    c.execute("""
        SELECT model_name,
               COUNT(*) as count,
               SUM(total_input_tokens) as input,
               SUM(total_output_tokens) as output,
               SUM(total_cached_tokens) as cached,
               SUM(call_count) as calls
        FROM traces
        GROUP BY model_name
        ORDER BY count DESC
    """)
    models = [dict(row) for row in c.fetchall()]

    # Top sessions by token usage
    if show_all:
        c.execute("""
            SELECT session_id,
                   MIN(started_at) as first_seen,
                   MAX(ended_at) as last_seen,
                   SUM(total_input_tokens) as total_input,
                   SUM(total_output_tokens) as total_output,
                   SUM(total_cached_tokens) as total_cached,
                   SUM(total_tokens) as total_tokens,
                   SUM(call_count) as total_calls,
                   COUNT(*) as trace_count
            FROM traces
            GROUP BY session_id
            ORDER BY first_seen DESC
        """)
    else:
        c.execute("""
            SELECT session_id,
                   MIN(started_at) as first_seen,
                   MAX(ended_at) as last_seen,
                   SUM(total_input_tokens) as total_input,
                   SUM(total_output_tokens) as total_output,
                   SUM(total_cached_tokens) as total_cached,
                   SUM(total_tokens) as total_tokens,
                   SUM(call_count) as total_calls,
                   COUNT(*) as trace_count
            FROM traces
            GROUP BY session_id
            ORDER BY first_seen DESC
            LIMIT ?
        """, (limit,))
    sessions = []
    for row in c.fetchall():
        d = dict(row)
        d["first_seen_str"] = datetime.fromtimestamp(d["first_seen"]).strftime("%m-%d %H:%M") if d["first_seen"] else ""
        d["last_seen_str"] = datetime.fromtimestamp(d["last_seen"]).strftime("%m-%d %H:%M") if d["last_seen"] else ""
        sessions.append(d)

    conn.close()
    return jsonify(
        overview=overview, latest=latest, models=models,
        sessions=sessions, session_limit=display_limit
    )


@app.route("/api/trends")
def api_trends():
    """Time-series snapshots for charts."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("""
        SELECT timestamp, total_input_all, total_output_all,
               total_cached_all, total_calls_all, trace_count
        FROM snapshots
        ORDER BY timestamp ASC
    """)
    trends = [dict(row) for row in c.fetchall()]
    conn.close()
    return jsonify(trends=trends)


@app.route("/api/hourly")
def api_hourly():
    """Hourly token usage for the last 7 days."""
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    cutoff = time.time() - 7 * 86400
    c.execute("""
        SELECT
            CAST(strftime('%Y-%m-%d %H:00', datetime(started_at, 'unixepoch', 'localtime')) AS TEXT) as hour,
            SUM(total_input_tokens) as input_tokens,
            SUM(total_output_tokens) as output_tokens,
            SUM(total_cached_tokens) as cached_tokens,
            SUM(call_count) as calls,
            COUNT(*) as traces
        FROM traces
        WHERE started_at > ?
        GROUP BY hour
        ORDER BY hour ASC
    """, (cutoff,))
    hourly = []
    for row in c.fetchall():
        hourly.append({
            "hour": row[0],
            "input_tokens": row[1],
            "output_tokens": row[2],
            "cached_tokens": row[3],
            "calls": row[4],
            "traces": row[5],
        })
    conn.close()
    return jsonify(hourly=hourly)


@app.route("/api/session/<session_id>")
def api_session_detail(session_id):
    """Detailed view of a specific session."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("""
        SELECT * FROM traces
        WHERE session_id = ?
        ORDER BY started_at ASC
    """, (session_id,))
    traces_raw = c.fetchall()

    traces = []
    for row in traces_raw:
        d = dict(row)
        d["started_at_str"] = datetime.fromtimestamp(d["started_at"]).strftime("%m-%d %H:%M:%S")
        traces.append(d)

    # Compute total for this session
    total_input = sum(t["total_input_tokens"] for t in traces)
    total_output = sum(t["total_output_tokens"] for t in traces)
    total_cached = sum(t["total_cached_tokens"] for t in traces)
    total_calls = sum(t["call_count"] for t in traces)
    total_duration = sum(t["duration"] for t in traces)

    conn.close()
    return jsonify(
        session_id=session_id,
        trace_count=len(traces),
        total_input=total_input,
        total_output=total_output,
        total_cached=total_cached,
        total_calls=total_calls,
        total_duration=total_duration,
        traces=traces,
    )


@app.route("/api/trace/<trace_id>")
def api_trace_detail(trace_id):
    """Fetch the full trace JSON from the filesystem."""
    trace_dir = os.path.expanduser(r"~\.workbuddy\traces")
    if not os.path.isdir(trace_dir):
        return jsonify(error="Trace directory not found"), 404

    for pid in os.listdir(trace_dir):
        pid_dir = os.path.join(trace_dir, pid)
        if not os.path.isdir(pid_dir):
            continue
        fpath = os.path.join(pid_dir, trace_id + ".json")
        if os.path.isfile(fpath):
            try:
                with open(fpath, "r", encoding="utf-8") as f:
                    data = json.load(f)
                # Spans are at the outer level, not inside "trace"
                trace = data.get("trace", data)
                spans = data.get("spans", trace.get("spans", []))
                trace["spans"] = spans
                return jsonify(trace)
            except (json.JSONDecodeError, IOError) as e:
                return jsonify(error=f"Failed to parse trace: {e}"), 500

    return jsonify(error="Trace not found"), 404


@app.route("/api/trace/<trace_id>/captures")
def api_trace_captures(trace_id):
    """Find captures related to a trace by time proximity.

    Matches captures whose `captured_at` falls within the trace's
    time window (with a 10s buffer on each side).
    """
    # 1. Read the trace file to get time bounds
    trace_dir = os.path.expanduser(r"~\.workbuddy\traces")
    trace_data = None
    if os.path.isdir(trace_dir):
        for pid in os.listdir(trace_dir):
            pid_dir = os.path.join(trace_dir, pid)
            if not os.path.isdir(pid_dir):
                continue
            fpath = os.path.join(pid_dir, trace_id + ".json")
            if os.path.isfile(fpath):
                try:
                    with open(fpath, "r", encoding="utf-8") as f:
                        trace_data = json.load(f)
                    break
                except (json.JSONDecodeError, IOError):
                    pass

    if not trace_data:
        return jsonify(error="Trace not found", captures=[])

    t = trace_data.get("trace", trace_data)
    started_at = t.get("startedAt", 0)
    ended_at = t.get("endedAt", 0)

    # Convert timestamps to seconds
    def ts_to_seconds(val):
        if val is None:
            return 0
        if isinstance(val, str):
            try:
                from datetime import datetime
                dt = datetime.fromisoformat(val.replace('Z', '+00:00'))
                return dt.timestamp()
            except (ValueError, TypeError):
                return 0
        try:
            v = float(val)
            return v / 1000 if v > 1e10 else v
        except (TypeError, ValueError):
            return 0

    start_sec = ts_to_seconds(started_at)
    end_sec = ts_to_seconds(ended_at)

    # Buffer: 10 seconds on each side
    query_start = start_sec - 10
    query_end = end_sec + 10

    # Extract model names from trace for secondary matching
    mi = t.get("modelInfo", {})
    trace_models = mi.get("models", [])
    session_id = t.get("sessionId", "")

    # 2. Query captures in the time window
    mod = _ensure_proxy_module()
    mod._init_captures_db()

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()

    c.execute("""
        SELECT id, hostname, method, path, model_name, status_code,
               captured_at, duration_ms, content_length
        FROM captures
        WHERE captured_at BETWEEN ? AND ?
        ORDER BY captured_at ASC
        LIMIT 50
    """, (query_start, query_end))
    rows = c.fetchall()
    conn.close()

    captures = []
    for row in rows:
        d = dict(row)
        d["captured_at_str"] = datetime.fromtimestamp(d["captured_at"]).strftime("%H:%M:%S")
        captures.append(d)

    return jsonify(
        trace_id=trace_id,
        session_id=session_id,
        started_at=start_sec,
        ended_at=end_sec,
        trace_models=trace_models,
        captures=captures,
    )


@app.route("/trace_detail.html")
def trace_detail():
    return render_template_string(TRACE_DETAIL_HTML)


@app.route("/api/sessions/list")
def api_sessions_list():
    """List all sessions with summary."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("""
        SELECT session_id,
               MIN(started_at) as first_seen,
               MAX(ended_at) as last_seen,
               SUM(total_input_tokens) as total_input,
               SUM(total_output_tokens) as total_output,
               SUM(total_cached_tokens) as total_cached,
               SUM(total_tokens) as total_tokens,
               SUM(call_count) as total_calls,
               COUNT(*) as trace_count,
               GROUP_CONCAT(DISTINCT model_name) as models
        FROM traces
        GROUP BY session_id
        ORDER BY first_seen DESC
    """)
    sessions = []
    for row in c.fetchall():
        d = dict(row)
        d["first_seen_str"] = datetime.fromtimestamp(d["first_seen"]).strftime("%m-%d %H:%M") if d["first_seen"] else ""
        d["last_seen_str"] = datetime.fromtimestamp(d["last_seen"]).strftime("%m-%d %H:%M") if d["last_seen"] else ""
        sessions.append(d)
    conn.close()
    return jsonify(sessions=sessions)


@app.route("/session_detail.html")
def session_detail():
    return render_template_string(SESSION_DETAIL_HTML)


@app.route("/api/agent")
def api_agent_breakdown():
    """Per-agent-name breakdown."""
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute("""
        SELECT agent_name,
               COUNT(*) as trace_count,
               SUM(total_input_tokens) as total_input,
               SUM(total_output_tokens) as total_output,
               SUM(total_cached_tokens) as total_cached,
               SUM(call_count) as calls
        FROM traces
        GROUP BY agent_name
        ORDER BY total_input DESC
    """)
    agents = [{"agent_name": row[0], "trace_count": row[1], "total_input": row[2],
               "total_output": row[3], "total_cached": row[4], "calls": row[5]}
              for row in c.fetchall()]
    conn.close()
    return jsonify(agents=agents)


# ========== LLM Proxy API (方案B) ==========

def _ensure_proxy_module(reload=False):
    """Lazy-load the proxy module, optionally force reload."""
    global llm_proxy
    if llm_proxy is None or reload:
        import importlib
        if llm_proxy is not None and reload:
            llm_proxy = importlib.reload(llm_proxy)
        else:
            llm_proxy = importlib.import_module("llm_proxy")
        llm_proxy.DB_PATH = DB_PATH
    return llm_proxy


@app.route("/api/proxy/status")
def api_proxy_status():
    """Get proxy server status."""
    try:
        mod = _ensure_proxy_module()
        mod._init_captures_db()
        status = mod.get_status()
        # Also count captures
        c = sqlite3.connect(DB_PATH)
        cur = c.cursor()
        cur.execute("SELECT COUNT(*) FROM captures")
        capture_count = cur.fetchone()[0]
        cur.execute("SELECT COUNT(*) FROM captures WHERE captured_at > ?", (time.time() - 86400,))
        today_count = cur.fetchone()[0]
        c.close()
        status["capture_count"] = capture_count
        status["today_captures"] = today_count
        return jsonify(status)
    except Exception as e:
        return jsonify(error=str(e), running=False), 500


# Shared error holder for proxy thread
_proxy_start_error = None

@app.route("/api/proxy/start", methods=["POST"])
def api_proxy_start():
    """Start the proxy server."""
    global _proxy_thread, _proxy_start_error
    try:
        if _proxy_thread and _proxy_thread.is_alive():
            return jsonify(message="Proxy is already running", running=True)

        mod = _ensure_proxy_module(reload=True)

        # Wrap start_proxy to capture errors
        def _start_with_error_catch():
            global _proxy_start_error
            try:
                mod.start_proxy()
            except Exception as e:
                _proxy_start_error = e
                log.error("Proxy thread error: %s", e)
                import traceback
                log.error(traceback.format_exc())

        _proxy_thread = threading.Thread(target=_start_with_error_catch, daemon=True)
        _proxy_thread.start()

        # Wait for proxy to actually start (poll _running flag, up to 5s)
        for i in range(10):
            time.sleep(0.5)
            if mod._running:
                return jsonify(message="Proxy started", running=True, port=mod.PROXY_PORT)
            # Check if thread died
            if not _proxy_thread.is_alive():
                error_msg = str(_proxy_start_error) if _proxy_start_error else "Unknown error in proxy thread"
                return jsonify(error=error_msg, running=False), 500

        return jsonify(error="Proxy failed to start within 5 seconds", running=False), 500
    except Exception as e:
        log.error("Failed to start proxy: %s", e)
        return jsonify(error=str(e), running=False), 500


@app.route("/api/proxy/stop", methods=["POST"])
def api_proxy_stop():
    """Stop the proxy server."""
    global _proxy_thread, _proxy_start_error
    try:
        mod = _ensure_proxy_module(reload=False)
        mod.stop_proxy()
        _proxy_thread = None
        _proxy_start_error = None
        return jsonify(message="Proxy stopped", running=False)
    except Exception as e:
        return jsonify(error=str(e)), 500


@app.route("/api/captures/list")
def api_captures_list():
    """List captured LLM requests/responses."""
    limit = request.args.get("limit", "50")
    try:
        limit = int(limit)
    except ValueError:
        limit = 50
    limit = min(limit, 200)

    mod = _ensure_proxy_module()
    mod._init_captures_db()

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("""
        SELECT id, hostname, method, path, status_code,
               captured_at, duration_ms, content_length, model_name
        FROM captures
        ORDER BY captured_at DESC
        LIMIT ?
    """, (limit,))
    captures = []
    for row in c.fetchall():
        d = dict(row)
        d["captured_at_str"] = datetime.fromtimestamp(d["captured_at"]).strftime("%H:%M:%S") if d["captured_at"] else ""
        captures.append(d)
    conn.close()
    return jsonify(captures=captures)


def _parse_sse_stream(body):
    """Parse SSE streaming data into readable content.
    
    Handles the `data: {...}` SSE format used by DeepSeek/OpenAI streaming APIs,
    extracting delta content and reasoning_content into a clean summary.
    Returns None if body is not SSE format.
    """
    if not body:
        return None
    lines = body.split("\n")
    sse_objects = []
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("data: "):
            json_str = stripped[6:]
            if json_str.strip() == "[DONE]":
                continue
            try:
                obj = json.loads(json_str)
                sse_objects.append(obj)
            except json.JSONDecodeError:
                continue
    if not sse_objects:
        return None

    # Extract delta content from streaming chunks
    content_parts = []
    reasoning_parts = []
    has_content = False

    for obj in sse_objects:
        choices = obj.get("choices") or []
        if not choices:
            continue
        delta = choices[0].get("delta") or {}
        if delta.get("content") is not None:
            content_parts.append(delta["content"])
            has_content = True
        if delta.get("reasoning_content") is not None:
            reasoning_parts.append(delta["reasoning_content"])

    if not has_content and not reasoning_parts:
        return None

    result = ""
    if reasoning_parts:
        result += "【推理过程】\n" + "".join(reasoning_parts) + "\n\n"
    if content_parts:
        result += "【回答内容】\n" + "".join(content_parts)

    # Check for usage info in the last object
    last_obj = sse_objects[-1] if sse_objects else None
    if last_obj and last_obj.get("usage"):
        result += "\n\n--- Token 用量 ---\n" + json.dumps(last_obj["usage"], indent=2, ensure_ascii=False)

    return result


@app.route("/api/captures/<int:capture_id>")
def api_capture_detail(capture_id):
    """Get full detail of a single capture."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("SELECT * FROM captures WHERE id = ?", (capture_id,))
    row = c.fetchone()
    conn.close()
    if not row:
        return jsonify(error="Capture not found"), 404
    data = dict(row)

    # Try to pretty-print JSON bodies
    for field in ("request_body", "response_body"):
        if data.get(field):
            try:
                obj = json.loads(data[field])
                data[field + "_formatted"] = json.dumps(obj, indent=2, ensure_ascii=False)
            except (json.JSONDecodeError, TypeError):
                data[field + "_formatted"] = data[field]

    # For response_body: try SSE stream parsing to get readable content
    raw_resp = data.get("response_body", "")
    sse_parsed = _parse_sse_stream(raw_resp)
    if sse_parsed:
        data["response_body_formatted"] = sse_parsed
        # Also store raw for reference
        data["response_body_raw"] = raw_resp

    return jsonify(data)


@app.route("/api/captures/export")
def api_captures_export():
    """Export all captures as a downloadable JSON file."""
    mod = _ensure_proxy_module()
    mod._init_captures_db()

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    c = conn.cursor()
    c.execute("""
        SELECT id, hostname, method, path, request_body, response_body,
               status_code, captured_at, duration_ms, content_length, model_name
        FROM captures
        ORDER BY captured_at ASC
    """)
    rows = c.fetchall()
    conn.close()

    export_data = []
    for row in rows:
        d = dict(row)
        # Try to parse JSON bodies for readability
        for field in ("request_body", "response_body"):
            if d.get(field):
                try:
                    obj = json.loads(d[field])
                    d[field + "_parsed"] = obj
                except (json.JSONDecodeError, TypeError):
                    pass
        export_data.append(d)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"llm_captures_export_{timestamp}.json"
    json_str = json.dumps(export_data, indent=2, ensure_ascii=False, default=str)
    return app.response_class(
        json_str,
        mimetype="application/json",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


@app.route("/captures.html")
def captures_page():
    return render_template_string(CAPTURES_HTML)


# ========== HTML Template ==========

HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WorkBuddy Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #0f172a; color: #e2e8f0; min-height: 100vh;
}
.layout { max-width: 1400px; margin: 0 auto; padding: 20px; }
.header {
    display: flex; justify-content: space-between; align-items: center;
    margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #1e293b;
}
.header h1 { font-size: 24px; background: linear-gradient(135deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.header .subtitle { color: #94a3b8; font-size: 14px; }
.refresh-btn {
    background: #1e293b; border: 1px solid #334155; color: #94a3b8;
    padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px;
    transition: all .2s;
}
.refresh-btn:hover { background: #334155; color: #e2e8f0; }
.refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.last-update { color: #64748b; font-size: 12px; margin-top: 4px; }

/* Overview Cards */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
.card {
    background: #1e293b; border-radius: 12px; padding: 16px;
    border: 1px solid #334155;
}
.card .label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
.card .value { font-size: 22px; font-weight: 700; margin-top: 6px; font-variant-numeric: tabular-nums; }
.card .sub { font-size: 12px; color: #64748b; margin-top: 4px; }
.card.primary .value { color: #38bdf8; }
.card.green .value { color: #34d399; }
.card.purple .value { color: #a78bfa; }
.card.orange .value { color: #fb923c; }
.card.red .value { color: #f87171; }
.card.pink .value { color: #f472b6; }
.card.teal .value { color: #2dd4bf; }

/* Grid layout */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 24px; }
@media (max-width: 900px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }

.panel {
    background: #1e293b; border-radius: 12px; padding: 20px;
    border: 1px solid #334155;
}
.panel h2 { font-size: 15px; color: #94a3b8; margin-bottom: 16px; }
.panel h2 .badge {
    float: right; background: #334155; padding: 2px 8px; border-radius: 4px;
    font-size: 12px; font-weight: 400; color: #64748b;
}

/* Table */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; color: #64748b; font-weight: 500; padding: 8px 6px; border-bottom: 1px solid #334155; }
td { padding: 8px 6px; border-bottom: 1px solid #1e293b; color: #cbd5e1; }
tr:hover td { background: #0f172a40; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.token-cell { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.clickable { cursor: pointer; color: #38bdf8; }
.clickable:hover { text-decoration: underline; }

/* Sortable table headers */
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { color: #e2e8f0; }
.sort-indicator { display: inline-block; width: 18px; text-align: center; font-size: 10px; color: #64748b; }
.sort-asc .sort-indicator { color: #38bdf8; }
.sort-desc .sort-indicator { color: #38bdf8; }

/* Bar in table */
.bar-bg { background: #0f172a; border-radius: 3px; height: 16px; overflow: hidden; position: relative; display: inline-block; }
.bar-fill { height: 100%; border-radius: 3px; transition: width .5s; min-width: 2px; }
.bar-label { position: absolute; right: 6px; top: 0; font-size: 11px; line-height: 16px; color: #e2e8f0; }

/* Model tag */
.model-tag {
    display: inline-block; padding: 2px 6px; border-radius: 4px;
    font-size: 11px; font-weight: 500;
}
.model-deepseek { background: #1e3a5f; color: #60a5fa; }
.model-minimax { background: #312e81; color: #a78bfa; }
.model-mimo { background: #1e293b; color: #34d399; }
.model-default { background: #334155; color: #94a3b8; }

/* Latest trace */
.latest-box { font-size: 13px; line-height: 1.8; }
.latest-box .item { display: flex; justify-content: space-between; padding: 4px 0; }
.latest-box .key { color: #64748b; }
.latest-box .val { color: #e2e8f0; font-family: "SF Mono", monospace; }

/* Loading */
.loading { text-align: center; color: #64748b; padding: 40px; }
.error { text-align: center; color: #f87171; padding: 40px; }

/* Most used sessions list */
.session-item {
    padding: 10px 0; border-bottom: 1px solid #1e293b;
}
.session-item:last-child { border-bottom: none; }
.session-item .sess-id { font-size: 12px; color: #38bdf8; word-break: break-all; }
.session-item .sess-meta { font-size: 11px; color: #64748b; margin-top: 2px; }
.session-item .sess-tokens { font-size: 13px; font-weight: 600; margin-top: 4px; }

/* charts */
.chart-container { position: relative; height: 220px; }
</style>
</head>
<body>
<div class="layout">
    <div class="header">
        <div>
            <h1>WorkBuddy Monitor</h1>
            <div class="subtitle">上下文内容 &amp; Token 用量监控</div>
            <div class="last-update" id="lastUpdate">上次更新: --</div>
        </div>
        <div>
            <button class="refresh-btn" onclick="refreshAll()" id="refreshBtn">刷新数据</button>
        </div>
    </div>

    <!-- Overview Cards -->
    <div class="cards" id="overviewCards">
        <div class="card primary"><div class="label">总输入 Tokens</div><div class="value" id="totInput">--</div><div class="sub" id="totCached">缓存命中: --</div></div>
        <div class="card green"><div class="label">总输出 Tokens</div><div class="value" id="totOutput">--</div><div class="sub">生成内容总量</div></div>
        <div class="card purple"><div class="label">总 Tokens</div><div class="value" id="totTokens">--</div><div class="sub" id="totCalls">模型调用: -- 次</div></div>
        <div class="card orange"><div class="label">Trace 数量</div><div class="value" id="traceCount">--</div><div class="sub" id="sessionCount">会话数: --</div></div>
        <div class="card red"><div class="label">今日 Tokens</div><div class="value" id="todayTokens">--</div><div class="sub" id="todayCalls">今日调用: -- 次</div></div>
    </div>

    <div class="grid-2">
        <!-- Token Trend -->
        <div class="panel">
            <h2>Token 累积趋势 <span class="badge">随时间增长</span></h2>
            <div class="chart-container"><canvas id="trendChart"></canvas></div>
        </div>
        <!-- Hourly Usage -->
        <div class="panel">
            <h2>最近 7 天小时级用量 <span class="badge">按小时聚合</span></h2>
            <div class="chart-container"><canvas id="hourlyChart"></canvas></div>
        </div>
    </div>

    <div class="grid-3">
        <!-- Model Breakdown -->
        <div class="panel">
            <h2>模型用量分布 <span class="badge">按模型</span></h2>
            <div class="chart-container"><canvas id="modelChart"></canvas></div>
        </div>
        <!-- Agent Breakdown -->
        <div class="panel">
            <h2>代理用量分布 <span class="badge">按代理</span></h2>
            <div class="chart-container"><canvas id="agentChart"></canvas></div>
        </div>
        <!-- Input/Output Pie -->
        <div class="panel">
            <h2>输入 vs 输出 <span class="badge">占比</span></h2>
            <div class="chart-container"><canvas id="ioPieChart"></canvas></div>
        </div>
    </div>

    <!-- Latest Trace -->
    <div class="panel" style="margin-bottom:24px;">
        <h2>最新 Trace 详情</h2>
        <div class="latest-box" id="latestTrace">等待数据...</div>
    </div>

    <!-- Model Detail Table -->
    <div class="panel" style="margin-bottom:24px;">
        <h2>模型详细统计</h2>
        <table>
            <thead><tr><th>模型</th><th class="num">Trace数</th><th class="num">调用次数</th><th class="num">输入 Tokens</th><th class="num">输出 Tokens</th><th class="num">缓存命中</th><th class="num">占比</th></tr></thead>
            <tbody id="modelTableBody"></tbody>
        </table>
    </div>

    <!-- Agent Detail Table -->
    <div class="panel" style="margin-bottom:24px;">
        <h2>代理详细统计</h2>
        <table>
            <thead><tr><th>代理</th><th class="num">Trace数</th><th class="num">调用次数</th><th class="num">输入 Tokens</th><th class="num">输出 Tokens</th><th class="num">缓存命中</th></tr></thead>
            <tbody id="agentTableBody"></tbody>
        </table>
    </div>

    <!-- Top Sessions -->
    <div class="panel" style="margin-bottom:24px;">
        <h2>
            会话展示 <span style="font-size:11px;color:#475569;font-weight:400;margin-left:8px;">点击排序 | Shift+点击组合排序</span>
            <span class="badge" style="font-weight:400;" id="sessionBadge">20 条</span>
            <span style="float:right;font-size:13px;font-weight:400;color:#64748b;">
                显示
                <select id="sessionLimitSelect" onchange="changeSessionLimit(this.value)" style="
                    background:#0f172a; color:#e2e8f0; border:1px solid #334155;
                    border-radius:4px; padding:2px 6px; font-size:13px; cursor:pointer;
                    outline:none;
                ">
                    <option value="5">5</option>
                    <option value="10">10</option>
                    <option value="20" selected>20</option>
                    <option value="50">50</option>
                    <option value="100">100</option>
                    <option value="200">200</option>
                    <option value="all">全部</option>
                </select>
                条
            </span>
        </h2>
        <table>
            <thead><tr>
                <th>会话 ID</th>
                <th class="num sortable" data-sort="total_tokens" onclick="toggleSort('total_tokens', event)">Token 总量 <span class="sort-indicator"></span></th>
                <th class="num sortable" data-sort="total_calls" onclick="toggleSort('total_calls', event)">调用次数 <span class="sort-indicator"></span></th>
                <th class="num sortable" data-sort="trace_count" onclick="toggleSort('trace_count', event)">Trace数 <span class="sort-indicator"></span></th>
                <th class="num sortable" data-sort="total_input" onclick="toggleSort('total_input', event)">输入 <span class="sort-indicator"></span></th>
                <th class="num sortable" data-sort="total_output" onclick="toggleSort('total_output', event)">输出 <span class="sort-indicator"></span></th>
                <th class="sortable" data-sort="first_seen" onclick="toggleSort('first_seen', event)">时间范围 <span class="sort-indicator"></span></th>
            </tr></thead>
            <tbody id="sessionsBody"></tbody>
        </table>
    </div>

    <!-- LLM Proxy (方案B) -->
    <div class="panel" style="margin-bottom:24px;border-color:#312e81;" id="proxyPanel">
        <h2 style="color:#a78bfa;">
            LLM 调用拦截
            <span class="badge" id="proxyStatusBadge" style="background:#451a1a;color:#f87171;">未启动</span>
        </h2>
        <div style="font-size:13px;color:#94a3b8;line-height:1.8;">
            <div class="grid-2" style="margin-bottom:12px;">
                <div>
                    <div><span style="color:#64748b;">代理地址:</span> <code style="color:#e2e8f0;background:#0f172a;padding:2px 6px;border-radius:3px;">http://127.0.0.1:5911</code></div>
                    <div style="margin-top:6px;"><span style="color:#64748b;">目标主机:</span> <span id="proxyTargets" style="color:#e2e8f0;">api.deepseek.com, api.minimaxi.com</span></div>
                    <div style="margin-top:6px;"><span style="color:#64748b;">CA 证书:</span> <code id="proxyCaPath" style="color:#a78bfa;background:#0f172a;padding:2px 6px;border-radius:3px;font-size:11px;">加载中...</code></div>
                </div>
                <div style="text-align:right;">
                    <button class="refresh-btn" id="proxyStartBtn" onclick="proxyStart()" style="display:inline-block;">启动代理</button>
                    <button class="refresh-btn" id="proxyStopBtn" onclick="proxyStop()" style="display:inline-block;border-color:#f87171;color:#f87171;">停止代理</button>
                    <div style="margin-top:8px;">
                        <span style="color:#64748b;font-size:12px;">已捕获: <span id="proxyCaptureCount" style="color:#a78bfa;font-weight:600;">0</span> 条</span>
                        <span style="color:#64748b;font-size:12px;margin-left:12px;">今日: <span id="proxyTodayCount" style="color:#34d399;font-weight:600;">0</span> 条</span>
                    </div>
                </div>
            </div>
            <div style="background:#1e1b4b;border:1px solid #312e81;border-radius:8px;padding:12px;font-size:12px;line-height:1.6;">
                <div style="color:#a78bfa;font-weight:600;margin-bottom:6px;">使用方法</div>
                <ol style="color:#94a3b8;padding-left:18px;">
                    <li>点击"启动代理"启动 MITM 代理服务</li>
                    <li>安装 CA 证书: 双击上方路径的 <code style="background:#0f172a;padding:1px 4px;border-radius:2px;">ca.crt</code> 文件，安装到"受信任的根证书颁发机构"</li>
                    <li>
                        设置环境变量（以管理员身份运行 PowerShell）：
                        <div style="background:#0f172a;border-radius:4px;padding:8px;margin:6px 0;font-family:monospace;font-size:11px;color:#94a3b8;">
# 启用拦截<br>
[Environment]::SetEnvironmentVariable("HTTPS_PROXY", "http://127.0.0.1:5911", "User")<br><br>
# 取消拦截<br>
[Environment]::SetEnvironmentVariable("HTTPS_PROXY", $null, "User")
                        </div>
                        设置后重启 WorkBuddy 生效
                    </li>
                    <li>新的 LLM 调用将自动被捕获，可在下方查看 <a href="/captures.html" style="color:#38bdf8;">对话记录</a></li>
                </ol>
            </div>
        </div>
    </div>
</div>

<script>
let trendChart = null, hourlyChart = null, modelChart = null, agentChart = null, ioPieChart = null;
let sessionLimit = 20;
let sortColumns = []; // Array of {col, asc}, e.g. [{col:'first_seen', asc:false}, {col:'total_tokens', asc:true}]
let cachedSessions = [];
let fullSessionsList = [];

function fmtNum(n) {
    if (n === undefined || n === null) return '--';
    if (n >= 1e9) return (n/1e9).toFixed(2) + 'B';
    if (n >= 1e6) return (n/1e6).toFixed(2) + 'M';
    if (n >= 1e3) return (n/1e3).toFixed(1) + 'K';
    return n.toLocaleString();
}

function fmtTime(ts) {
    const d = new Date(ts * 1000);
    return d.toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
}

function modelClass(name) {
    if (!name) return 'model-default';
    name = name.toLowerCase();
    if (name.includes('deepseek')) return 'model-deepseek';
    if (name.includes('minimax') || name.includes('m2')) return 'model-minimax';
    if (name.includes('mimo')) return 'model-mimo';
    return 'model-default';
}

function modelLabel(name) {
    if (!name) return 'unknown';
    return name.replace('custom-local:', '');
}

function formatDuration(ms) {
    if (!ms) return '-';
    if (ms < 1000) return ms + 'ms';
    if (ms < 60000) return (ms/1000).toFixed(1) + 's';
    return (ms/60000).toFixed(1) + 'm';
}

async function refreshAll() {
    const btn = document.getElementById('refreshBtn');
    btn.disabled = true;
    btn.textContent = '刷新中...';

    try {
        const [overview, trends, hourly, sessions, agents] = await Promise.all([
            fetch('/api/overview?limit=' + sessionLimit).then(r=>r.json()),
            fetch('/api/trends').then(r=>r.json()),
            fetch('/api/hourly').then(r=>r.json()),
            fetch('/api/sessions/list').then(r=>r.json()),
            fetch('/api/agent').then(r=>r.json()),
        ]);

        document.getElementById('lastUpdate').textContent = '上次更新: ' + new Date().toLocaleTimeString('zh-CN');

        // Overview cards
        const ov = overview.overview;
        document.getElementById('totInput').textContent = fmtNum(ov.total_input);
        document.getElementById('totOutput').textContent = fmtNum(ov.total_output);
        document.getElementById('totTokens').textContent = fmtNum(ov.total_tokens_all);
        document.getElementById('totCalls').textContent = '模型调用: ' + fmtNum(ov.total_calls) + ' 次';
        document.getElementById('totCached').textContent = '缓存命中: ' + fmtNum(ov.total_cached) + ' (' + (ov.total_input > 0 ? (ov.total_cached/ov.total_input*100).toFixed(1) : 0) + '%)';
        document.getElementById('traceCount').textContent = fmtNum(ov.trace_count);
        document.getElementById('sessionCount').textContent = '会话数: ' + fmtNum(ov.session_count);

        // Today's tokens
        const now = Date.now() / 1000;
        const todayStart = new Date(); todayStart.setHours(0,0,0,0);
        const tsToday = todayStart.getTime() / 1000;
        const sessList = sessions.sessions || [];
        let todayTokens = 0, todayCalls = 0;
        for (const s of sessList) {
            if (s.first_seen >= tsToday) {
                todayTokens += s.total_tokens || 0;
                todayCalls += s.total_calls || 0;
            }
        }
        document.getElementById('todayTokens').textContent = fmtNum(todayTokens);
        document.getElementById('todayCalls').textContent = '今日调用: ' + fmtNum(todayCalls) + ' 次';

        // Latest trace
        const latest = overview.latest;
        if (latest) {
            document.getElementById('latestTrace').innerHTML = `
                <div class="item"><span class="key">Trace ID</span><span class="val">${latest.trace_id}</span></div>
                <div class="item"><span class="key">会话</span><span class="val">${latest.session_id}</span></div>
                <div class="item"><span class="key">模型</span><span class="val"><span class="model-tag ${modelClass(latest.model_name)}">${modelLabel(latest.model_name)}</span></span></div>
                <div class="item"><span class="key">时间</span><span class="val">${latest.started_at_str}</span></div>
                <div class="item"><span class="key">输入 / 输出 / 缓存 Tokens</span><span class="val">${fmtNum(latest.total_input_tokens)} / ${fmtNum(latest.total_output_tokens)} / ${fmtNum(latest.total_cached_tokens)}</span></div>
                <div class="item"><span class="key">调用次数 / 耗时</span><span class="val">${latest.call_count} 次 / ${formatDuration(latest.duration)}</span></div>
                <div class="item"><span class="key">状态 / Span数</span><span class="val">${latest.status} / ${latest.span_count}</span></div>
            `;
        } else {
            document.getElementById('latestTrace').textContent = '暂无数据';
        }

        // Model table
        const models = overview.models || [];
        const totalModelTokens = models.reduce((s, m) => s + (m.input || 0) + (m.output || 0), 0) || 1;
        document.getElementById('modelTableBody').innerHTML = models.map(m =>
            `<tr>
                <td><span class="model-tag ${modelClass(m.model_name)}">${modelLabel(m.model_name)}</span></td>
                <td class="num">${m.count}</td>
                <td class="num">${fmtNum(m.calls)}</td>
                <td class="num token-cell">${fmtNum(m.input)}</td>
                <td class="num token-cell">${fmtNum(m.output)}</td>
                <td class="num token-cell">${fmtNum(m.cached)}</td>
                <td class="num">
                    <div class="bar-bg" style="width:120px;">
                        <div class="bar-fill" style="width:${((m.input+m.output)/totalModelTokens*100).toFixed(1)}%;background:#38bdf8;"></div>
                        <span class="bar-label">${((m.input+m.output)/totalModelTokens*100).toFixed(1)}%</span>
                    </div>
                </td>
            </tr>`
        ).join('');

        // Agent table
        const agentData = agents.agents || [];
        const totalAgentTokens = agentData.reduce((s, a) => s + (a.total_input || 0), 0) || 1;
        document.getElementById('agentTableBody').innerHTML = agentData.map(a =>
            `<tr>
                <td>${a.agent_name || 'unknown'}</td>
                <td class="num">${a.trace_count}</td>
                <td class="num">${fmtNum(a.calls)}</td>
                <td class="num token-cell">${fmtNum(a.total_input)}</td>
                <td class="num token-cell">${fmtNum(a.total_output)}</td>
                <td class="num token-cell">${fmtNum(a.total_cached)}</td>
            </tr>`
        ).join('');

        // Top sessions table - cache and sort
        const topSessions = overview.sessions || [];
        cachedSessions = topSessions;
        fullSessionsList = sessions.sessions || [];
        renderSessionsTable();

        // Update session limit badge
        if (overview.session_limit) {
            const badge = document.getElementById('sessionBadge');
            if (badge) badge.textContent = topSessions.length + ' 条';
        }

        // Charts
        buildTrendChart(trends.trends || []);
        buildHourlyChart(hourly.hourly || []);
        buildModelChart(models);
        buildAgentChart(agentData);
        buildIOPieChart(ov);

    } catch(e) {
        console.error('Refresh error:', e);
        document.querySelectorAll('.value').forEach(el => el.textContent = 'ERR');
    }

    btn.disabled = false;
    btn.textContent = '刷新数据';
}

function buildTrendChart(trends) {
    if (trendChart) trendChart.destroy();
    const labels = trends.map(t => new Date(t.timestamp * 1000).toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'}));
    const ctx = document.getElementById('trendChart').getContext('2d');
    trendChart = new Chart(ctx, {
        type: 'line',
        data: {
            labels: labels,
            datasets: [
                { label: '总输入', data: trends.map(t=>t.total_input_all), borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.1)', fill: true, tension: 0.3, pointRadius: 0 },
                { label: '总输出', data: trends.map(t=>t.total_output_all), borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.1)', fill: true, tension: 0.3, pointRadius: 0 },
                { label: '缓存命中', data: trends.map(t=>t.total_cached_all), borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.05)', fill: true, tension: 0.3, pointRadius: 0 },
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { labels: { color: '#94a3b8', boxWidth: 12 } } },
            scales: {
                x: { ticks: { color: '#64748b', maxTicksLimit: 10 }, grid: { color: '#1e293b' } },
                y: { ticks: { color: '#64748b', callback: v => fmtNum(v) }, grid: { color: '#1e293b' } }
            }
        }
    });
}

function buildHourlyChart(hourly) {
    if (hourlyChart) hourlyChart.destroy();
    const labels = hourly.map(h => h.hour);
    const ctx = document.getElementById('hourlyChart').getContext('2d');
    hourlyChart = new Chart(ctx, {
        type: 'bar',
        data: {
            labels: labels,
            datasets: [
                { label: '输入', data: hourly.map(h=>h.input_tokens), backgroundColor: 'rgba(56,189,248,0.7)', borderColor: '#38bdf8', borderWidth: 1 },
                { label: '输出', data: hourly.map(h=>h.output_tokens), backgroundColor: 'rgba(52,211,153,0.7)', borderColor: '#34d399', borderWidth: 1 },
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { labels: { color: '#94a3b8', boxWidth: 12 } } },
            scales: {
                x: { ticks: { color: '#64748b', maxTicksLimit: 12 }, grid: { color: '#1e293b' } },
                y: { stacked: true, ticks: { color: '#64748b', callback: v => fmtNum(v) }, grid: { color: '#1e293b' } }
            }
        }
    });
}

function buildModelChart(models) {
    if (modelChart) modelChart.destroy();
    const colors = ['#38bdf8','#a78bfa','#34d399','#fb923c','#f87171','#f472b6','#2dd4bf'];
    const ctx = document.getElementById('modelChart').getContext('2d');
    modelChart = new Chart(ctx, {
        type: 'doughnut',
        data: {
            labels: models.map(m => modelLabel(m.model_name)),
            datasets: [{
                data: models.map(m => (m.input||0) + (m.output||0)),
                backgroundColor: colors.slice(0, models.length),
                borderWidth: 0,
            }]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {
                legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 12, padding: 8 } }
            },
            cutout: '55%',
        }
    });
}

function buildAgentChart(agents) {
    if (agentChart) agentChart.destroy();
    const top = agents.slice(0, 5);
    const colors = ['#38bdf8','#34d399','#a78bfa','#fb923c','#f87171'];
    const ctx = document.getElementById('agentChart').getContext('2d');
    agentChart = new Chart(ctx, {
        type: 'doughnut',
        data: {
            labels: top.map(a => a.agent_name || 'unknown'),
            datasets: [{
                data: top.map(a => a.total_input),
                backgroundColor: colors.slice(0, top.length),
                borderWidth: 0,
            }]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {
                legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 12, padding: 8 } }
            },
            cutout: '55%',
        }
    });
}

function buildIOPieChart(ov) {
    if (ioPieChart) ioPieChart.destroy();
    const ctx = document.getElementById('ioPieChart').getContext('2d');
    const input = ov.total_input || 1;
    const output = ov.total_output || 0;
    const cached = ov.total_cached || 0;
    ioPieChart = new Chart(ctx, {
        type: 'doughnut',
        data: {
            labels: ['输入 Tokens', '输出 Tokens', '缓存命中'],
            datasets: [{
                data: [input, output, cached],
                backgroundColor: ['#38bdf8', '#34d399', '#a78bfa'],
                borderWidth: 0,
            }]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {
                legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 12, padding: 8 } }
            },
            cutout: '50%',
        }
    });
}

function showSession(sessionId) {
    window.open(`/session_detail.html?session_id=${encodeURIComponent(sessionId)}`, '_blank');
}

function changeSessionLimit(val) {
    sessionLimit = val;
    refreshAll();
}

function getSortValue(obj, col) {
    if (col === 'first_seen') return obj.first_seen || 0;
    return obj[col];
}

function renderSessionsTable() {
    let data;
    if (sortColumns.length > 0) {
        // Any active sort uses the full session list, applies sort, then limits
        data = [...fullSessionsList];
        data.sort((a, b) => {
            for (const sc of sortColumns) {
                let va = getSortValue(a, sc.col);
                let vb = getSortValue(b, sc.col);
                if (va === undefined || va === null) va = 0;
                if (vb === undefined || vb === null) vb = 0;
                if (typeof va === 'string') va = va.toLowerCase();
                if (typeof vb === 'string') vb = vb.toLowerCase();
                if (va < vb) return sc.asc ? -1 : 1;
                if (va > vb) return sc.asc ? 1 : -1;
            }
            return 0;
        });
        // Apply limit after sorting
        const limit = sessionLimit === 'all' ? data.length : parseInt(sessionLimit);
        if (!isNaN(limit) && limit > 0 && limit < data.length) {
            data = data.slice(0, limit);
        }
    } else {
        // No active sort: use cached sessions (latest N, no hidden token sort)
        data = [...cachedSessions];
    }

    document.getElementById('sessionsBody').innerHTML = data.map(s =>
        `<tr>
            <td class="clickable" onclick="showSession('${s.session_id}')" title="查看详情">${s.session_id.substring(0, 20)}...</td>
            <td class="num token-cell">${fmtNum(s.total_tokens)}</td>
            <td class="num">${fmtNum(s.total_calls)}</td>
            <td class="num">${s.trace_count}</td>
            <td class="num token-cell">${fmtNum(s.total_input)}</td>
            <td class="num token-cell">${fmtNum(s.total_output)}</td>
            <td class="num" style="font-size:11px;color:#64748b;">${s.first_seen_str} ~ ${s.last_seen_str}</td>
        </tr>`
    ).join('');

    // Update badge
    const badge = document.getElementById('sessionBadge');
    if (badge) badge.textContent = data.length + ' 条';
}

function toggleSort(col, event) {
    if (event && event.shiftKey) {
        // Shift+click: add as secondary/tertiary sort
        const existing = sortColumns.find(sc => sc.col === col);
        if (existing) {
            existing.asc = !existing.asc;
        } else {
            sortColumns.push({ col: col, asc: false });
        }
    } else {
        // Regular click: replace with single-column sort
        if (sortColumns.length === 1 && sortColumns[0].col === col) {
            sortColumns[0].asc = !sortColumns[0].asc;
        } else {
            sortColumns = [{ col: col, asc: false }];
        }
    }

    // Update visual indicators on headers
    document.querySelectorAll('.sortable').forEach(th => {
        th.classList.remove('sort-asc', 'sort-desc');
        const indicator = th.querySelector('.sort-indicator');
        if (indicator) indicator.textContent = '';
    });
    sortColumns.forEach((sc, idx) => {
        const header = document.querySelector(`.sortable[data-sort="${sc.col}"]`);
        if (header) {
            header.classList.add(sc.asc ? 'sort-asc' : 'sort-desc');
            const indicator = header.querySelector('.sort-indicator');
            if (indicator) {
                const arrow = sc.asc ? '▲' : '▼';
                indicator.textContent = sortColumns.length > 1 ? (idx + 1) + arrow : arrow;
            }
        }
    });

    renderSessionsTable();
}

// ---- LLM Proxy control ----
async function refreshProxyStatus() {
    try {
        const res = await fetch('/api/proxy/status');
        const data = await res.json();
        const badge = document.getElementById('proxyStatusBadge');
        if (data.running) {
            badge.textContent = '运行中 :5911';
            badge.style.background = '#064e3b';
            badge.style.color = '#34d399';
            document.getElementById('proxyStartBtn').style.display = 'none';
            document.getElementById('proxyStopBtn').style.display = 'inline-block';
        } else {
            badge.textContent = '未启动';
            badge.style.background = '#451a1a';
            badge.style.color = '#f87171';
            document.getElementById('proxyStartBtn').style.display = 'inline-block';
            document.getElementById('proxyStopBtn').style.display = 'none';
        }
        if (data.ca_cert_path) {
            document.getElementById('proxyCaPath').textContent = data.ca_cert_path;
        }
        if (data.target_hosts) {
            document.getElementById('proxyTargets').textContent = data.target_hosts.join(', ');
        }
        document.getElementById('proxyCaptureCount').textContent = data.capture_count || 0;
        document.getElementById('proxyTodayCount').textContent = data.today_captures || 0;
    } catch(e) {
        console.log('Proxy status check failed (expected if not started yet)');
    }
}

async function proxyStart() {
    const btn = document.getElementById('proxyStartBtn');
    btn.disabled = true;
    btn.textContent = '启动中...';
    try {
        const res = await fetch('/api/proxy/start', { method: 'POST' });
        const data = await res.json();
        if (data.running) {
            btn.textContent = '已启动';
            setTimeout(refreshProxyStatus, 500);
        } else {
            btn.textContent = '启动失败';
            // Show error in status badge if available
            if (data.error) {
                const badge = document.getElementById('proxyStatusBadge');
                badge.textContent = '错误: ' + data.error;
                badge.style.background = '#451a1a';
                badge.style.color = '#f87171';
            }
        }
    } catch(e) {
        btn.textContent = '启动错误';
        const badge = document.getElementById('proxyStatusBadge');
        badge.textContent = '请求失败: ' + e.message;
        badge.style.background = '#451a1a';
        badge.style.color = '#f87171';
    }
    setTimeout(() => { btn.disabled = false; }, 2000);
}

async function proxyStop() {
    const btn = document.getElementById('proxyStopBtn');
    btn.disabled = true;
    btn.textContent = '停止中...';
    try {
        await fetch('/api/proxy/stop', { method: 'POST' });
        btn.textContent = '已停止';
        setTimeout(refreshProxyStatus, 500);
    } catch(e) {
        btn.textContent = '停止错误';
    }
    setTimeout(() => { btn.disabled = false; }, 2000);
}

// Auto refresh
document.addEventListener('DOMContentLoaded', () => {
    refreshAll();
    refreshProxyStatus();
    setInterval(refreshAll, 15000);
    setInterval(refreshProxyStatus, 30000);
});
</script>
</body>
</html>
"""

# ========== Session Detail HTML Template ==========

SESSION_DETAIL_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>会话详情 - WorkBuddy Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #0f172a; color: #e2e8f0; padding: 24px;
}
h1 { font-size: 20px; margin-bottom: 16px; color: #38bdf8; word-break: break-all; }
h2 { font-size: 15px; color: #94a3b8; margin: 20px 0 12px; }
.back { display: inline-block; margin-bottom: 16px; color: #64748b; text-decoration: none; font-size: 13px; }
.back:hover { color: #38bdf8; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
.card { background: #1e293b; border-radius: 12px; padding: 14px; border: 1px solid #334155; }
.card .label { font-size: 11px; color: #64748b; }
.card .value { font-size: 20px; font-weight: 700; margin-top: 4px; }
.chart-container { position: relative; height: 250px; margin-bottom: 20px; }
.panel { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; color: #64748b; font-weight: 500; padding: 6px; border-bottom: 1px solid #334155; }
td { padding: 6px; border-bottom: 1px solid #1e293b; color: #cbd5e1; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.loading { text-align: center; color: #64748b; padding: 40px; }
</style>
</head>
<body>
<a class="back" href="/">&larr; 返回仪表盘</a>
<h1 id="sessionTitle">会话详情</h1>

<div class="cards" id="summaryCards">
    <div class="card"><div class="label">Trace 数量</div><div class="value" id="traceCount">--</div></div>
    <div class="card"><div class="label">总调用次数</div><div class="value" id="totalCalls">--</div></div>
    <div class="card"><div class="label">总输入 Tokens</div><div class="value" id="totalInput">--</div></div>
    <div class="card"><div class="label">总输出 Tokens</div><div class="value" id="totalOutput">--</div></div>
    <div class="card"><div class="label">缓存命中</div><div class="value" id="totalCached">--</div></div>
    <div class="card"><div class="label">总耗时</div><div class="value" id="totalDuration">--</div></div>
</div>

<div class="panel">
    <h2>Trace 调用序列</h2>
    <div class="chart-container"><canvas id="traceChart"></canvas></div>
</div>

<div class="panel">
    <h2>所有 Trace 列表</h2>
    <table>
        <thead><tr><th>Trace ID</th><th>模型</th><th class="num">输入</th><th class="num">输出</th><th class="num">缓存</th><th class="num">调用次数</th><th class="num">耗时</th><th>时间</th></tr></thead>
        <tbody id="traceTableBody"></tbody>
    </table>
</div>

<script>
const params = new URLSearchParams(location.search);
const sessionId = params.get('session_id');
document.getElementById('sessionTitle').textContent = '会话: ' + (sessionId ? sessionId.substring(0,24)+'...' : '?');

async function load() {
    const res = await fetch('/api/session/' + encodeURIComponent(sessionId));
    const data = await res.json();

    document.getElementById('traceCount').textContent = data.trace_count;
    document.getElementById('totalCalls').textContent = data.total_calls;
    document.getElementById('totalInput').textContent = fmt(data.total_input);
    document.getElementById('totalOutput').textContent = fmt(data.total_output);
    document.getElementById('totalCached').textContent = fmt(data.total_cached);
    document.getElementById('totalDuration').textContent = formatDuration(data.total_duration);

    // Table
    document.getElementById('traceTableBody').innerHTML = data.traces.map(t =>
        `<tr>
            <td><a href="/trace_detail.html?trace_id=${encodeURIComponent(t.trace_id)}" style="font-size:11px;color:#38bdf8;text-decoration:none;" title="查看详情">${t.trace_id.substring(0,16)}...</a></td>
            <td><span class="model-tag ${modelClass(t.model_name)}">${modelLabel(t.model_name)}</span></td>
            <td class="num">${fmt(t.total_input_tokens)}</td>
            <td class="num">${fmt(t.total_output_tokens)}</td>
            <td class="num">${fmt(t.total_cached_tokens)}</td>
            <td class="num">${t.call_count}</td>
            <td class="num">${formatDuration(t.duration)}</td>
            <td style="font-size:11px;color:#64748b;">${t.started_at_str || ''}</td>
        </tr>`
    ).join('');

    // Chart
    const ctx = document.getElementById('traceChart').getContext('2d');
    new Chart(ctx, {
        type: 'bar',
        data: {
            labels: data.traces.map((_, i) => '#' + (i+1)),
            datasets: [
                { label: '输入', data: data.traces.map(t=>t.total_input_tokens), backgroundColor: 'rgba(56,189,248,0.7)', borderColor: '#38bdf8', borderWidth: 1 },
                { label: '输出', data: data.traces.map(t=>t.total_output_tokens), backgroundColor: 'rgba(52,211,153,0.7)', borderColor: '#34d399', borderWidth: 1 },
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { labels: { color: '#94a3b8', boxWidth: 12 } } },
            scales: {
                x: { ticks: { color: '#64748b' }, grid: { color: '#1e293b' } },
                y: { ticks: { color: '#64748b', callback: v=>fmt(v) }, grid: { color: '#1e293b' } }
            }
        }
    });
}

function fmt(n) { if (!n) return '0'; if (n>=1e9) return (n/1e9).toFixed(1)+'B'; if (n>=1e6) return (n/1e6).toFixed(1)+'M'; if (n>=1e3) return (n/1e3).toFixed(1)+'K'; return n.toLocaleString(); }
function formatDuration(ms) { if (!ms) return '-'; if (ms<1000) return ms+'ms'; if (ms<60000) return (ms/1000).toFixed(1)+'s'; return (ms/60000).toFixed(1)+'m'; }
function modelClass(n) { n=(n||'').toLowerCase(); return n.includes('deepseek')?'model-deepseek':n.includes('minimax')||n.includes('m2')?'model-minimax':n.includes('mimo')?'model-mimo':'model-default'; }
function modelLabel(n) { return (n||'').replace('custom-local:',''); }

document.addEventListener('DOMContentLoaded', load);
</script>
</body>
</html>
"""

# ========== Trace Detail HTML Template ==========

TRACE_DETAIL_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trace 详情 - WorkBuddy Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #0f172a; color: #e2e8f0; padding: 24px;
}
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 18px; margin-bottom: 8px; word-break: break-all; color: #38bdf8; }
.trace-id { font-size: 12px; color: #64748b; margin-bottom: 20px; word-break: break-all; }
.back { display: inline-block; margin-bottom: 16px; color: #64748b; font-size: 13px; }
.back:hover { color: #38bdf8; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 20px; }
.card { background: #1e293b; border-radius: 10px; padding: 12px; border: 1px solid #334155; }
.card .label { font-size: 11px; color: #64748b; }
.card .value { font-size: 18px; font-weight: 700; margin-top: 3px; }
.panel { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; margin-bottom: 16px; }
.panel h2 { font-size: 14px; color: #94a3b8; margin-bottom: 12px; }

/* Filter bar */
.filter-bar {
    display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
    margin-bottom: 14px;
}
.filter-btn {
    background: #1e293b; border: 1px solid #334155; color: #94a3b8;
    padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;
    transition: all 0.15s;
}
.filter-btn:hover { background: #334155; color: #e2e8f0; }
.filter-btn.active { background: #1e3a5f; border-color: #38bdf8; color: #38bdf8; }
.filter-btn .count { color: #64748b; margin-left: 3px; font-size: 11px; }
.filter-btn.active .count { color: #60a5fa; }
.search-input {
    background: #0f172a; border: 1px solid #334155; color: #e2e8f0;
    padding: 5px 10px; border-radius: 6px; font-size: 12px;
    width: 200px; outline: none; flex: 1; min-width: 120px;
}
.search-input:focus { border-color: #38bdf8; }
.search-input::placeholder { color: #475569; }
.expand-all-btn {
    background: transparent; border: 1px solid #334155; color: #64748b;
    padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 11px;
    white-space: nowrap;
}
.expand-all-btn:hover { color: #e2e8f0; border-color: #64748b; }

/* Span items */
.span-list { list-style: none; }
.span-item {
    background: #0f172a; border-radius: 8px; padding: 12px 14px;
    margin-bottom: 8px; border: 1px solid #1e293b;
    transition: opacity 0.15s;
}
.span-item.filtered-out { display: none; }
.span-item.search-hidden { opacity: 0.3; }
.span-header {
    display: flex; justify-content: space-between; align-items: center;
    margin-bottom: 6px; flex-wrap: wrap; gap: 6px;
}
.span-name { font-weight: 600; font-size: 13px; color: #e2e8f0; font-family: "SF Mono", "Fira Code", monospace; }
.span-type-badge {
    display: inline-block; padding: 1px 6px; border-radius: 3px;
    font-size: 10px; font-weight: 500;
}
.type-agent { background: #1e3a5f; color: #60a5fa; }
.type-generation { background: #312e81; color: #a78bfa; }
.type-function { background: #064e3b; color: #34d399; }
.type-custom { background: #451a1a; color: #f87171; }
.span-meta { font-size: 11px; color: #64748b; }
.span-status { font-size: 11px; }
.status-ok { color: #34d399; }
.status-error { color: #f87171; }

/* Content area */
.span-content {
    margin-top: 8px; background: #0a0f1e; border-radius: 6px;
    padding: 10px; overflow-x: auto; font-size: 12px;
    font-family: "SF Mono", "Fira Code", monospace; line-height: 1.6;
    max-height: 400px; overflow-y: auto; white-space: pre-wrap;
    word-break: break-all;
}
.span-content .key { color: #60a5fa; }
.span-content .string { color: #34d399; }
.span-content .number { color: #fbbf24; }
.span-content .null { color: #64748b; }
.content-toggle {
    font-size: 11px; color: #38bdf8; cursor: pointer; margin-top: 4px;
    display: inline-block;
}
.content-toggle:hover { text-decoration: underline; }
.hidden-content { display: none; }

/* Generation info banner */
.gen-info-banner {
    background: #1e1b4b; border: 1px solid #312e81; border-radius: 6px;
    padding: 8px 10px; margin-top: 8px; font-size: 11px;
    display: flex; gap: 12px; flex-wrap: wrap;
}
.gen-info-banner .gen-stat { display: flex; flex-direction: column; }
.gen-info-banner .gen-stat-label { color: #64748b; font-size: 10px; }
.gen-info-banner .gen-stat-value { color: #a78bfa; font-size: 12px; font-weight: 600; }
.gen-info-banner .gen-note { color: #64748b; font-size: 11px; line-height: 1.4; }

/* Tool output content - for function spans */
.func-output {
    margin-top: 6px; background: #0a0f1e; border-left: 3px solid #065f46;
    border-radius: 0 4px 4px 0; padding: 8px 10px; font-size: 11px;
    line-height: 1.5; max-height: 300px; overflow-y: auto;
    white-space: pre-wrap; word-break: break-all;
}
.func-output-header {
    display: flex; justify-content: space-between; align-items: center;
    margin-bottom: 4px;
}
.func-output-label {
    color: #10b981; font-weight: 600; font-size: 10px;
    text-transform: uppercase; letter-spacing: 0.5px;
}
.func-output-truncated {
    color: #f59e0b; font-size: 10px;
}
.func-output-text { color: #94a3b8; }

/* Content count badge */
.content-count-badge {
    font-size: 10px; color: #475569; margin-left: 6px;
}

/* Loading and error */
.loading { text-align: center; color: #64748b; padding: 40px; }
.error { text-align: center; color: #f87171; padding: 40px; }
.raw-json-btn {
    background: #1e293b; border: 1px solid #334155; color: #94a3b8;
    padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px;
    float: right;
}
.raw-json-btn:hover { background: #334155; color: #e2e8f0; }

/* Related captures */
.related-capture-item {
    background: #0f172a; border-radius: 6px; padding: 8px 10px;
    margin-bottom: 6px; border: 1px solid #1e293b;
    transition: border-color 0.15s;
}
.related-capture-item:hover { border-color: #334155; }
.related-capture-header {
    display: flex; justify-content: space-between; align-items: center;
    flex-wrap: wrap; gap: 4px; cursor: pointer; user-select: none;
}
.related-capture-model { color: #60a5fa; font-size: 11px; font-weight: 600; }
.related-capture-path { color: #94a3b8; font-size: 10px; font-family: monospace; }
.related-capture-meta { font-size: 10px; color: #64748b; }
.related-capture-detail {
    display: none; margin-top: 6px; padding-top: 6px;
    border-top: 1px solid #1e293b;
}
.related-capture-detail.open { display: block; }
.related-empty { color: #475569; font-size: 12px; padding: 12px; text-align: center; }

/* Request/Response visual distinction */
.rel-section-label {
    font-size: 11px; font-weight: 600; padding: 4px 8px;
    border-radius: 4px 4px 0 0; margin: 8px 0 0 0;
}
.rel-section-label.req {
    background: #1e3a5f33; color: #38bdf8;
    border-left: 3px solid #38bdf8;
}
.rel-section-label.resp {
    background: #064e3b33; color: #34d399;
    border-left: 3px solid #34d399;
}
.rel-section-label.meta {
    background: #1e293b; color: #94a3b8;
    border-left: 3px solid #64748b; font-weight: 400;
}
.rel-content-block {
    background: #0a0f1e; padding: 8px 10px;
    font-size: 11px; font-family: "SF Mono", "Fira Code", monospace;
    line-height: 1.5; overflow-x: auto; white-space: pre-wrap;
    word-break: break-all; color: #94a3b8;
    max-height: 180px; overflow-y: auto;
    border-radius: 0 0 4px 4px;
}
.rel-content-block.expanded { max-height: none; }
.rel-content-block.req-block { border-left: 3px solid #1e3a5f; }
.rel-content-block.resp-block { border-left: 3px solid #064e3b; }
.rel-content-toggle {
    font-size: 10px; color: #38bdf8; cursor: pointer;
    display: inline-block; margin: 2px 0 0 8px;
}
.rel-content-toggle:hover { text-decoration: underline; }
.rel-meta-grid {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
    gap: 4px; padding: 6px 10px; font-size: 10px; color: #64748b;
    background: #0a0f1e; border-radius: 0 0 4px 4px;
    border-left: 3px solid #64748b;
}
.rel-meta-grid .rel-meta-item { }
.rel-meta-grid .rel-meta-key { color: #475569; }
.rel-meta-grid .rel-meta-val { color: #94a3b8; }
</style>
</head>
<body>
<a class="back" href="javascript:history.back()">&larr; 返回</a>
<div id="content">
    <div class="loading">加载中...</div>
</div>

<script>
const params = new URLSearchParams(location.search);
const traceId = params.get('trace_id');

// ---- Utility functions ----
function fmt(n) {
    if (!n || n === 0) return '0';
    if (n >= 1e9) return (n/1e9).toFixed(2) + 'B';
    if (n >= 1e6) return (n/1e6).toFixed(2) + 'M';
    if (n >= 1e3) return (n/1e3).toFixed(1) + 'K';
    return n.toLocaleString();
}

function fmtTime(ts) {
    if (!ts) return '-';
    const d = new Date(ts);
    return d.toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
}

function fmtDuration(ms) {
    if (!ms && ms !== 0) return '-';
    if (ms < 1000) return ms + 'ms';
    if (ms < 60000) return (ms/1000).toFixed(1) + 's';
    const m = Math.floor(ms / 60000);
    const s = (ms % 60000) / 1000;
    return m + 'm ' + s.toFixed(1) + 's';
}

function typeBadgeClass(type) {
    if (!type) return 'type-custom';
    if (type === 'agent') return 'type-agent';
    if (type === 'generation') return 'type-generation';
    if (type === 'function') return 'type-function';
    return 'type-custom';
}

function escapeHtml(str) {
    if (!str) return '';
    return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;');
}

function downloadText(content, filename) {
    if (!content || content === '(空)') return;
    const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
}

function tryParseJson(str) {
    try {
        const obj = JSON.parse(str);
        return JSON.stringify(obj, null, 2);
    } catch(e) {
        return null;
    }
}

function formatSpanContent(obj) {
    if (obj === null || obj === undefined) return '<span class="null">null</span>';
    if (typeof obj === 'string') {
        // Try to pretty-print JSON strings
        const parsed = tryParseJson(obj);
        if (parsed && parsed.length > 0) {
            const display = parsed.length > 5000 ? parsed.substring(0, 5000) + '\n... (截断)' : parsed;
            return '<span class="string">' + escapeHtml(display) + '</span>';
        }
        return '<span class="string">"' + escapeHtml(obj.substring(0, 5000)) + '"</span>';
    }
    if (typeof obj === 'number') return '<span class="number">' + obj + '</span>';
    if (typeof obj === 'boolean') return '<span class="number">' + obj + '</span>';
    try {
        const formatted = JSON.stringify(obj, null, 2);
        if (formatted.length > 8000) {
            return escapeHtml(formatted.substring(0, 8000)) + '\n... (截断)';
        }
        return escapeHtml(formatted);
    } catch(e) {
        return escapeHtml(String(obj));
    }
}

// Extract content text from toolOutput for search purposes
function getSpanSearchText(span) {
    const parts = [span.name || '', span.type || '', span.toolName || '', span.status || ''];
    if (span.toolInput) parts.push(String(span.toolInput));
    if (span.toolOutput) parts.push(String(span.toolOutput));
    if (span.error) parts.push(String(span.error));
    if (span.agentName) parts.push(String(span.agentName));
    return parts.join(' ').toLowerCase();
}

// ---- Main render ----
async function load() {
    const container = document.getElementById('content');

    try {
        const res = await fetch('/api/trace/' + encodeURIComponent(traceId));
        if (!res.ok) {
            container.innerHTML = '<div class="error">Trace 未找到 (404)</div>';
            return;
        }
        const trace = await res.json();

        const startedStr = fmtTime(trace.startedAt);
        const endedStr = fmtTime(trace.endedAt);
        const durationMs = trace.duration;
        const modelName = trace.modelInfo?.models?.join(', ') || 'unknown';
        const mi = trace.modelInfo || {};

        const spans = trace.spans || [];

        // Count span types
        let genCount = 0, funcCount = 0, customCount = 0;
        spans.forEach(s => {
            if (s.type === 'generation') genCount++;
            else if (s.type === 'function') funcCount++;
            else if (s.type === 'custom') customCount++;
        });

        // Shared state for filters
        let currentFilter = 'all';
        let searchTerm = '';

        function renderSpans(filter, search) {
            let filteredSpans = spans.filter(s => {
                if (filter === 'generation' && s.type !== 'generation') return false;
                if (filter === 'function' && s.type !== 'function') return false;
                if (filter === 'custom' && s.type !== 'custom') return false;
                return true;
            });

            // Calculate estimated tokens per generation (approximate)
            const genSpans = spans.filter(s => s.type === 'generation');
            const avgInputPerGen = genSpans.length > 0 ? Math.round((mi.totalInputTokens || 0) / genSpans.length) : 0;
            const avgOutputPerGen = genSpans.length > 0 ? Math.round((mi.totalOutputTokens || 0) / genSpans.length) : 0;

            let html = '';
            if (filteredSpans.length === 0) {
                html = '<div style="color:#64748b;font-size:13px;text-align:center;padding:20px;">没有匹配的 span</div>';
            } else {
                html = '<div class="span-list">';
                filteredSpans.forEach((span, idx) => {
                    const origIdx = spans.indexOf(span);
                    const typeClass = typeBadgeClass(span.type);
                    const statusClass = span.status === 'ok' ? 'status-ok' : 'status-error';
                    const spanDuration = span.endedAt && span.startedAt
                        ? new Date(span.endedAt) - new Date(span.startedAt)
                        : span.duration || 0;

                    // Search highlight
                    const searchText = getSpanSearchText(span);
                    const matchesSearch = search === '' || searchText.includes(search.toLowerCase());
                    const searchHidden = search !== '' && !matchesSearch;

                    let contentHtml = '';

                    // ---- Generation span: special treatment ----
                    if (span.type === 'generation') {
                        contentHtml += '<div class="gen-info-banner">';
                        contentHtml += '<div class="gen-stat"><span class="gen-stat-label">预估输入</span><span class="gen-stat-value">' + fmt(avgInputPerGen) + '</span></div>';
                        contentHtml += '<div class="gen-stat"><span class="gen-stat-label">预估输出</span><span class="gen-stat-value">' + fmt(avgOutputPerGen) + '</span></div>';
                        contentHtml += '<div class="gen-stat"><span class="gen-stat-label">耗时</span><span class="gen-stat-value">' + fmtDuration(spanDuration) + '</span></div>';
                        contentHtml += '<div class="gen-note">&#x2139; prompt/response 未存储在 trace 数据中。可在对应 function span 的 toolOutput 中查看 AI 回复内容。</div>';
                        contentHtml += '</div>';
                    }

                    // ---- Function span: show toolInput/toolOutput ----
                    if (span.type === 'function') {
                        const hasOutput = span.toolOutput && span.toolOutput.length > 0;
                        const hasInput = span.toolInput && span.toolInput.length > 0;
                        const truncated = hasOutput && (
                            span.toolOutput.endsWith('... [truncated]') ||
                            span.toolOutput.endsWith('...')
                        );

                        if (hasInput || hasOutput) {
                            if (hasOutput) {
                                const outputContent = formatSpanContent(span.toolOutput);
                                contentHtml += '<div class="func-output">';
                                contentHtml += '<div class="func-output-header">';
                                contentHtml += '<span class="func-output-label">' + escapeHtml(span.toolName || '工具输出') + '</span>';
                                if (truncated) {
                                    contentHtml += '<span class="func-output-truncated">&#x26a0; 输出被截断</span>';
                                }
                                contentHtml += '</div>';
                                contentHtml += '<div class="func-output-text">' + outputContent + '</div>';
                                contentHtml += '</div>';
                            }
                            if (hasInput) {
                                contentHtml += '<div class="func-output" style="border-left-color:#6366f1;">';
                                contentHtml += '<div class="func-output-header">';
                                contentHtml += '<span class="func-output-label">工具输入</span>';
                                contentHtml += '</div>';
                                contentHtml += '<div class="func-output-text">' + formatSpanContent(span.toolInput) + '</div>';
                                contentHtml += '</div>';
                            }
                        } else {
                            contentHtml += '<div style="color:#475569;font-size:11px;margin-top:4px;">无工具输入/输出记录</div>';
                        }

                        // Show other metadata
                        let metaParts = [];
                        if (span.error) metaParts.push('<span class="key">Error:</span> <span style="color:#f87171;">' + escapeHtml(span.error.substring(0, 500)) + '</span>');
                        if (span.agentName) metaParts.push('<span class="key">Agent:</span> ' + escapeHtml(span.agentName));
                        if (metaParts.length > 0) {
                            contentHtml += '<div style="margin-top:6px;font-size:11px;color:#64748b;">' + metaParts.join(' | ') + '</div>';
                        }
                    }

                    // ---- Custom span: show attributes ----
                    if (span.type === 'custom') {
                        if (span.attributes && Object.keys(span.attributes).length > 0) {
                            contentHtml += '<div class="func-output" style="border-left-color:#dc2626;">';
                            contentHtml += '<div class="func-output-header"><span class="func-output-label">属性</span></div>';
                            contentHtml += '<div class="func-output-text">' + formatSpanContent(span.attributes) + '</div>';
                            contentHtml += '</div>';
                        }
                    }

                    // ---- Agent span: show agentName ----
                    if (span.type === 'agent' && span.agentName) {
                        contentHtml += '<div style="margin-top:6px;font-size:11px;color:#60a5fa;">Agent: ' + escapeHtml(span.agentName) + '</div>';
                    }

                    const itemClasses = [];
                    if (searchHidden) itemClasses.push('search-hidden');

                    html += '<div class="span-item ' + itemClasses.join(' ') + '" data-span-type="' + escapeHtml(span.type || '') + '" data-span-name="' + escapeHtml(span.name || '') + '">';
                    html += '<div class="span-header">';
                    html += '<div>';
                    html += '<span class="span-name">#' + origIdx + ' ' + escapeHtml(span.name || 'unnamed') + '</span> ';
                    html += '<span class="span-type-badge ' + typeClass + '">' + escapeHtml(span.type || 'custom') + '</span> ';
                    if (span.toolName) html += '<span style="font-size:10px;color:#475569;">' + escapeHtml(span.toolName) + '</span> ';
                    html += '<span class="span-status ' + statusClass + '">' + escapeHtml(span.status || '') + '</span>';
                    if (span.error) html += ' <span class="span-status status-error">ERR</span>';
                    html += '</div>';
                    html += '<span class="span-meta">' + fmtDuration(spanDuration) + (span.spanId ? ' | ' + span.spanId.substring(0, 10) + '...' : '') + '</span>';
                    html += '</div>';

                    // Content area (collapsible if long)
                    if (contentHtml.length > 0) {
                        const contentId = 'content_' + origIdx;
                        const isLong = contentHtml.length > 600;
                        html += '<div class="span-content' + (isLong ? ' hidden-content' : '') + '" id="' + contentId + '">' + contentHtml + '</div>';
                        if (isLong) {
                            html += '<span class="content-toggle" onclick="document.getElementById(\'' + contentId + '\').classList.toggle(\'hidden-content\');this.textContent=this.textContent.indexOf(\'展开\')>=0?\'收起\':\'展开详情\'">展开详情</span>';
                        }
                    }

                    html += '</div>';
                });
                html += '</div>';
            }

            document.getElementById('spans-container').innerHTML = html;
        }

        // ---- Build the page ----
        container.innerHTML = `
            <a class="back" href="javascript:history.back()">&larr; 返回会话详情</a>
            <h1>Trace 详情</h1>
            <div class="trace-id">${escapeHtml(traceId)}</div>

            <div class="cards">
                <div class="card"><div class="label">模型</div><div class="value" style="font-size:14px;color:#60a5fa;">${escapeHtml(modelName)}</div></div>
                <div class="card"><div class="label">总 Tokens</div><div class="value">${fmt(trace.totalTokens || 0)}</div></div>
                <div class="card"><div class="label">输入 Tokens</div><div class="value" style="color:#38bdf8;">${fmt(mi.totalInputTokens || 0)}</div></div>
                <div class="card"><div class="label">输出 Tokens</div><div class="value" style="color:#34d399;">${fmt(mi.totalOutputTokens || 0)}</div></div>
                <div class="card"><div class="label">缓存命中</div><div class="value" style="color:#a78bfa;">${fmt(mi.totalCachedTokens || 0)}</div></div>
                <div class="card"><div class="label">调用次数</div><div class="value">${mi.callCount || 0}</div></div>
                <div class="card"><div class="label">Spans</div><div class="value">${trace.spanCount || 0}</div></div>
                <div class="card"><div class="label">耗时</div><div class="value" style="font-size:14px;">${fmtDuration(durationMs)}</div></div>
            </div>

            <div class="panel">
                <h2>基本信息</h2>
                <table style="width:100%;font-size:13px;">
                    <tr><td style="color:#64748b;width:120px;padding:4px 0;">Trace ID</td><td style="padding:4px 0;word-break:break-all;">${escapeHtml(traceId)}</td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">会话 ID</td><td style="padding:4px 0;word-break:break-all;">${escapeHtml(trace.sessionId || '')}</td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">Agent</td><td style="padding:4px 0;">${escapeHtml(trace.agentName || '')}</td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">状态</td><td style="padding:4px 0;"><span class="span-status ${trace.status === 'ok' ? 'status-ok' : 'status-error'}">${escapeHtml(trace.status || '')}</span></td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">开始时间</td><td style="padding:4px 0;">${startedStr}</td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">结束时间</td><td style="padding:4px 0;">${endedStr}</td></tr>
                    <tr><td style="color:#64748b;padding:4px 0;">Worker</td><td style="padding:4px 0;">PID ${trace.workerPid || ''} @ ${escapeHtml(trace.workerHostname || '')}</td></tr>
                </table>
            </div>

            <div class="panel">
                <h2>Span 调用序列 (${spans.length})</h2>

                <div class="filter-bar">
                    <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">全部 <span class="count">${spans.length}</span></button>
                    <button class="filter-btn" data-filter="generation" onclick="setFilter('generation')">AI推理 <span class="count">${genCount}</span></button>
                    <button class="filter-btn" data-filter="function" onclick="setFilter('function')">工具调用 <span class="count">${funcCount}</span></button>
                    <button class="filter-btn" data-filter="custom" onclick="setFilter('custom')">其他 <span class="count">${customCount}</span></button>
                    <input class="search-input" id="search-input" type="text" placeholder="搜索 span 内容..." oninput="onSearch(this.value)">
                    <button class="expand-all-btn" onclick="toggleAllContent()">展开全部</button>
                </div>

                <div id="spans-container">
                    <div class="loading">加载中...</div>
                </div>
            </div>

            <div class="panel" id="related-captures-panel">
                <h2>📡 关联的捕获记录 <span id="related-count" style="font-weight:400;font-size:12px;color:#64748b;">查询中...</span></h2>
                <div id="related-captures-container">
                    <div class="related-empty">正在匹配代理捕获的记录...</div>
                </div>
            </div>

            <div class="panel">
                <h2>原始 Trace 数据 <a href="/api/trace/${encodeURIComponent(traceId)}" target="_blank" style="font-size:12px;font-weight:400;float:right;color:#64748b;">查看原始 JSON &rarr;</a></h2>
                <pre style="background:#0a0f1e;border-radius:6px;padding:12px;font-size:11px;line-height:1.5;overflow-x:auto;max-height:500px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#94a3b8;">${escapeHtml(JSON.stringify(trace, null, 2))}</pre>
            </div>
        `;

        // Initial render
        renderSpans('all', '');

        // Load related captures
        loadRelatedCaptures(traceId);

        // Expose filter/search functions globally
        window.setFilter = function(filter) {
            currentFilter = filter;
            document.querySelectorAll('.filter-btn').forEach(b => {
                b.classList.toggle('active', b.dataset.filter === filter);
            });
            renderSpans(filter, searchTerm);
        };

        window.onSearch = function(value) {
            searchTerm = value;
            renderSpans(currentFilter, searchTerm);
        };

        window.toggleAllContent = function() {
            const contents = document.querySelectorAll('.span-content.hidden-content');
            const toggles = document.querySelectorAll('.content-toggle');
            if (contents.length === 0) {
                // All are visible -> hide all long ones
                document.querySelectorAll('.span-content').forEach(el => {
                    if (el.innerHTML.length > 600) el.classList.add('hidden-content');
                });
                toggles.forEach(t => { t.textContent = '展开全部'; });
            } else {
                // Hide none - show all
                document.querySelectorAll('.span-content').forEach(el => el.classList.remove('hidden-content'));
                toggles.forEach(t => { t.textContent = '收起'; });
            }
        };

    } catch(e) {
        container.innerHTML = '<div class="error">加载失败: ' + escapeHtml(e.message) + '</div>';
    }
}

// ---- Related Captures ----
async function loadRelatedCaptures(traceId) {
    const container = document.getElementById('related-captures-container');
    const countEl = document.getElementById('related-count');
    try {
        const res = await fetch('/api/trace/' + encodeURIComponent(traceId) + '/captures');
        const data = await res.json();
        const captures = data.captures || [];

        if (captures.length === 0) {
            countEl.textContent = '(0)';
            container.innerHTML = '<div class="related-empty">未匹配到代理捕获的记录。请确保 MITM 代理正在运行且配置了代理设置。</div>';
            return;
        }

        countEl.textContent = '(' + captures.length + ')';
        let html = '';
        captures.forEach((cap, idx) => {
            const statusClass = cap.status_code >= 200 && cap.status_code < 300 ? 'status-ok' : 'status-error';
            const detailId = 'rel-cap-detail-' + idx;
            html += '<div class="related-capture-item">';
            html += '<div class="related-capture-header" onclick="toggleRelatedCapture(\'' + detailId + '\', ' + cap.id + ')">';
            html += '<div><span class="related-capture-model">' + escapeHtml(cap.model_name || '') + '</span> ';
            html += '<span class="related-capture-path">' + escapeHtml(cap.method || '') + ' ' + escapeHtml(cap.path || '') + '</span></div>';
            html += '<div><span class="related-capture-meta">' + escapeHtml(cap.captured_at_str || '') + ' | <span class="' + statusClass + '">' + (cap.status_code || '') + '</span> | ' + (cap.duration_ms || 0) + 'ms | ' + fmtSize(cap.content_length || 0) + '</span></div>';
            html += '</div>';
            html += '<div class="related-capture-detail" id="' + detailId + '"><div class="loading" style="padding:8px;">加载详情...</div></div>';
            html += '</div>';
        });
        container.innerHTML = html;
    } catch(e) {
        countEl.textContent = '(错误)';
        container.innerHTML = '<div class="related-empty">加载关联捕获失败: ' + escapeHtml(e.message) + '</div>';
    }
}

let loadedRelatedDetails = {};

async function toggleRelatedCapture(detailId, captureId) {
    const panel = document.getElementById(detailId);
    if (!panel) return;
    if (panel.classList.contains('open')) {
        panel.classList.remove('open');
        return;
    }
    panel.classList.add('open');

    if (loadedRelatedDetails[captureId]) {
        panel.innerHTML = loadedRelatedDetails[captureId];
        return;
    }

    try {
        const res = await fetch('/api/captures/' + captureId);
        const detail = await res.json();
        if (detail.error) {
            panel.innerHTML = '<div class="related-empty">加载失败</div>';
            return;
        }

        const reqText = detail.request_body_formatted || detail.request_body || '';
        const hasPrettyResp = detail.response_body_formatted && detail.response_body_formatted !== detail.response_body;
        const respText = hasPrettyResp ? detail.response_body_formatted : (detail.response_body_formatted || detail.response_body || '');

        // Store full text globally for safe toggle access
        window._relTexts = window._relTexts || {};
        window._relTexts[captureId] = { req: reqText, resp: respText };

        let html = '';

        // ---- Request (Prompt) ----
        const reqTruncated = reqText.length > 800;
        html += '<div class="rel-section-label req">&#x25B6; 请求 (Prompt)'
            + ' <span class="rel-content-toggle" style="font-size:10px;margin-left:6px;"'
            + ' onclick="event.stopPropagation();downloadText(window._relTexts[' + captureId + '].req, \'prompt-' + captureId + '.txt\')">&#x2913; 导出 Prompt</span>'
            + '</div>';
        html += '<div class="rel-content-block req-block" id="rel-req-' + captureId + '">';
        html += escapeHtml(reqTruncated ? reqText.substring(0, 800) + '\n...\n' : (reqText || '(空)'));
        html += '</div>';
        if (reqTruncated) {
            html += '<span class="rel-content-toggle" onclick="toggleRelFull(' + captureId + ',\'req\')">展开完整请求</span>';
        }

        // ---- Response ----
        const respTruncated = respText.length > 800;
        html += '<div class="rel-section-label resp">&#x25C0; 响应 (Response)'
            + ' <span class="rel-content-toggle" style="font-size:10px;margin-left:6px;"'
            + ' onclick="event.stopPropagation();downloadText(window._relTexts[' + captureId + '].resp, \'response-' + captureId + '.txt\')">&#x2913; 导出 Response</span>'
            + '</div>';

        if (hasPrettyResp) {
            const rid = 'rel-resp-' + captureId;
            html += '<div style="margin:2px 0 0 8px;"><span class="toggle-btn" onclick="toggleRelRespView(\'' + rid + '\')" id="' + rid + '-btn">显示原始数据</span></div>';
            html += '<div class="rel-content-block resp-block" id="' + rid + '-pretty">';
            html += escapeHtml(respTruncated ? respText.substring(0, 800) + '\n...\n' : respText);
            html += '</div>';
            if (respTruncated) {
                html += '<span class="rel-content-toggle" onclick="toggleRelFull(' + captureId + ',\'resp\')">展开完整回复</span>';
            }
            html += '<div class="rel-content-block resp-block" id="' + rid + '-raw" style="display:none;">' + escapeHtml(detail.response_body_raw) + '</div>';
        } else {
            html += '<div class="rel-content-block resp-block" id="rel-resp-' + captureId + '">';
            html += escapeHtml(respTruncated ? respText.substring(0, 800) + '\n...\n' : (respText || '(空)'));
            html += '</div>';
            if (respTruncated) {
                html += '<span class="rel-content-toggle" onclick="toggleRelFull(' + captureId + ',\'resp\')">展开完整回复</span>';
            }
        }

        // ---- Metadata (compact grid) ----
        html += '<div class="rel-section-label meta">&#x2139; 元数据</div>';
        html += '<div class="rel-meta-grid">';
        if (detail.model_name) html += '<div class="rel-meta-item"><span class="rel-meta-key">模型</span><br><span class="rel-meta-val">' + escapeHtml(detail.model_name) + '</span></div>';
        html += '<div class="rel-meta-item"><span class="rel-meta-key">路径</span><br><span class="rel-meta-val">' + escapeHtml(detail.method || '') + ' ' + escapeHtml(detail.path || '') + '</span></div>';
        html += '<div class="rel-meta-item"><span class="rel-meta-key">状态</span><br><span class="rel-meta-val">' + (detail.status_code || '') + '</span></div>';
        html += '<div class="rel-meta-item"><span class="rel-meta-key">耗时</span><br><span class="rel-meta-val">' + (detail.duration_ms || 0) + 'ms</span></div>';
        html += '<div class="rel-meta-item"><span class="rel-meta-key">大小</span><br><span class="rel-meta-val">' + fmtSize(detail.content_length || 0) + '</span></div>';
        html += '<div class="rel-meta-item"><span class="rel-meta-key">时间</span><br><span class="rel-meta-val">' + fmtTime(detail.captured_at * 1000) + '</span></div>';
        html += '</div>';

        panel.innerHTML = html;
        loadedRelatedDetails[captureId] = html;
    } catch(e) {
        panel.innerHTML = '<div class="related-empty">加载失败: ' + escapeHtml(e.message) + '</div>';
    }
}

function toggleRelFull(captureId, type) {
    const el = document.getElementById('rel-' + type + '-' + captureId);
    if (!el) return;
    const texts = window._relTexts && window._relTexts[captureId];
    if (!texts) return;
    if (el.classList.contains('expanded')) {
        el.classList.remove('expanded');
        el.textContent = texts[type].substring(0, 800) + '\n...\n';
        const toggleEl = el.parentNode.querySelector('.rel-content-toggle');
        if (toggleEl) toggleEl.textContent = '展开完整' + (type === 'req' ? '请求' : '回复');
    } else {
        el.classList.add('expanded');
        el.textContent = texts[type];
        const toggleEl = el.parentNode.querySelector('.rel-content-toggle');
        if (toggleEl) toggleEl.textContent = '收起';
    }
}

function toggleRelRespView(rid) {
    const pretty = document.getElementById(rid + '-pretty');
    const raw = document.getElementById(rid + '-raw');
    const btn = document.getElementById(rid + '-btn');
    if (!pretty || !raw || !btn) return;
    if (pretty.style.display !== 'none') {
        pretty.style.display = 'none';
        raw.style.display = 'block';
        btn.textContent = '显示格式化内容';
    } else {
        pretty.style.display = 'block';
        raw.style.display = 'none';
        btn.textContent = '显示原始数据';
    }
}

function fmtSize(bytes) {
    if (!bytes) return '0B';
    if (bytes < 1024) return bytes + 'B';
    if (bytes < 1048576) return (bytes/1024).toFixed(1) + 'KB';
    return (bytes/1048576).toFixed(1) + 'MB';
}

document.addEventListener('DOMContentLoaded', () => {
    load();
});
</script>
</body>
</html>
"""

# ========== Captures/Conversations Page ==========

CAPTURES_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>对话记录 - WorkBuddy Monitor</title>
<style>
* { margin:0; padding:0; box-sizing: border-box; }
body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #0f172a; color: #e2e8f0; padding: 20px 24px;
}
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 18px; color: #a78bfa; margin-bottom: 6px; }
.subtitle { font-size: 12px; color: #64748b; margin-bottom: 14px; }
.back { display: inline-block; margin-bottom: 12px; color: #64748b; font-size: 13px; }
.back:hover { color: #38bdf8; }

/* Top bar */
.top-bar {
    display: flex; align-items: center; gap: 10px;
    flex-wrap: wrap; margin-bottom: 14px;
}
.search-input {
    flex: 1; min-width: 180px; max-width: 400px;
    background: #1e293b; border: 1px solid #334155; color: #e2e8f0;
    padding: 7px 12px; border-radius: 8px; font-size: 13px;
    outline: none; transition: border-color .15s;
}
.search-input:focus { border-color: #38bdf8; }
.search-input::placeholder { color: #475569; }
.count-badge { font-size: 12px; color: #64748b; white-space: nowrap; }
.export-btn {
    margin-left: auto;
    background: #1e3a5f; border: 1px solid #38bdf8; color: #38bdf8;
    padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 12px;
    transition: all .15s; white-space: nowrap;
}
.export-btn:hover { background: #1e4978; color: #7dd3fc; }
.export-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* Card */
.capture-card {
    background: #1e293b; border-radius: 10px;
    border: 1px solid #334155;
    margin-bottom: 6px; overflow: hidden;
    transition: border-color .15s;
}
.capture-card:hover { border-color: #475569; }

.card-header {
    display: flex; align-items: center; gap: 8px;
    padding: 10px 14px; cursor: pointer; user-select: none;
    flex-wrap: wrap;
}
.card-header:hover { background: #1e293bcc; }

.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-dot.ok { background: #34d399; }
.status-dot.err { background: #f87171; }

.card-title {
    flex: 1; min-width: 0; display: flex; align-items: center;
    gap: 8px; flex-wrap: wrap;
}
.card-method {
    font-size: 11px; font-weight: 700; font-family: "SF Mono", monospace;
    padding: 1px 5px; border-radius: 3px; text-transform: uppercase;
}
.card-method.post { background: #1e3a5f; color: #60a5fa; }
.card-method.get { background: #064e3b; color: #34d399; }
.card-host { font-size: 12px; color: #94a3b8; font-family: monospace; }
.card-path { font-size: 11px; color: #475569; font-family: monospace; }

.card-meta { display: flex; align-items: center; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.meta-pill {
    font-size: 10px; padding: 2px 6px; border-radius: 4px;
    background: #0f172a; color: #64748b; white-space: nowrap;
}
.meta-pill.model { background: #1e1b4b; color: #a78bfa; }
.meta-pill.time { color: #475569; }
.meta-pill.size { color: #475569; }

.expand-icon {
    font-size: 10px; color: #475569; transition: transform .15s;
    flex-shrink: 0; width: 16px; text-align: center;
}
.expand-icon.open { transform: rotate(90deg); color: #94a3b8; }

/* Detail */
.card-detail { display: none; border-top: 1px solid #263548; }
.card-detail.open { display: block; }

.detail-tabs {
    display: flex; gap: 0;
    border-bottom: 1px solid #263548; padding: 0 14px;
}
.detail-tab {
    padding: 7px 12px; font-size: 11px; color: #64748b;
    cursor: pointer; border-bottom: 2px solid transparent;
    transition: all .1s; user-select: none;
}
.detail-tab:hover { color: #94a3b8; }
.detail-tab.active { color: #38bdf8; border-bottom-color: #38bdf8; }

.detail-body { padding: 10px 14px; }
.content-panel { display: none; }
.content-panel.active { display: block; }

/* Code */
.code-block {
    background: #0a0f1e; border-radius: 6px;
    padding: 10px 12px; font-size: 11px;
    font-family: "SF Mono", "Fira Code", monospace;
    line-height: 1.55; overflow-x: auto;
    white-space: pre-wrap; word-break: break-all;
    color: #94a3b8; max-height: 220px; overflow-y: auto;
    border: 1px solid #1e293b;
}
.code-block.expanded { max-height: none; }
.code-block.pretty { color: #cbd5e1; }

.toggle-expand {
    display: inline-block; font-size: 10px; color: #38bdf8;
    cursor: pointer; margin-top: 4px; user-select: none;
}
.toggle-expand:hover { text-decoration: underline; }

/* Meta grid */
.meta-grid {
    display: flex; gap: 10px; flex-wrap: wrap; font-size: 11px;
    padding: 8px 12px; background: #0f172a; border-radius: 6px;
    border: 1px solid #1e293b;
}
.meta-item { display: flex; gap: 4px; align-items: center; }
.meta-key { color: #475569; }
.meta-val { color: #94a3b8; }

.empty { text-align: center; color: #475569; padding: 40px 20px; font-size: 13px; }
.loading { text-align: center; color: #64748b; padding: 40px 20px; }
</style>
</head>
<body>
<a class="back" href="/">&larr; 返回仪表盘</a>
<h1>LLM 对话记录</h1>
<div class="subtitle">通过 MITM 代理捕获的 AI 请求 / 响应</div>

<div class="top-bar">
    <input class="search-input" id="searchInput" type="text"
           placeholder="搜索模型名、路径、主机..." oninput="filterCaptures()">
    <span class="count-badge">共 <strong id="captureTotal">0</strong> 条</span>
    <button class="export-btn" id="exportBtn" onclick="exportCaptures()">&#x2913; 导出全部</button>
</div>

<div id="capturesList"><div class="loading">加载中...</div></div>

<script>
let allCaptures = [];

function escapeHtml(s) {
    if (!s) return '';
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(ts) {
    if (!ts) return '-';
    const d = new Date(ts * 1000);
    return d.toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
function fmtSize(b) {
    if (!b) return '0B';
    if (b < 1024) return b + 'B';
    if (b < 1048576) return (b/1024).toFixed(1) + 'KB';
    return (b/1048576).toFixed(1) + 'MB';
}
function methodClass(m) {
    if (!m) return 'get';
    return m.toUpperCase() === 'POST' ? 'post' : 'get';
}
function statusOk(code) { return code >= 200 && code < 300; }

/* Download helper */
function downloadText(content, filename) {
    if (!content || content === '(空)') return;
    const blob = new Blob([content], {type:'text/plain;charset=utf-8'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = filename;
    document.body.appendChild(a); a.click();
    document.body.removeChild(a); URL.revokeObjectURL(url);
}

/* ---- Load ---- */
async function loadCaptures() {
    const container = document.getElementById('capturesList');
    try {
        const res = await fetch('/api/captures/list?limit=200');
        const data = await res.json();
        allCaptures = data.captures || [];
        renderList(allCaptures);
    } catch(e) {
        container.innerHTML = '<div class="empty">加载失败：' + escapeHtml(e.message) + '</div>';
    }
}

/* ---- Render ---- */
function renderList(captures) {
    const container = document.getElementById('capturesList');
    document.getElementById('captureTotal').textContent = captures.length;
    if (captures.length === 0) {
        container.innerHTML = '<div class="empty">暂无捕获数据<br><span style="font-size:11px;color:#64748b;margin-top:6px;display:block;">请先启动代理并配置 WorkBuddy 使用代理</span></div>';
        return;
    }
    let html = '';
    captures.forEach(cap => {
        const ok = statusOk(cap.status_code);
        const detailId = 'detail-' + cap.id;
        html += '<div class="capture-card" id="card-' + cap.id + '">';
        /* Header */
        html += '<div class="card-header" onclick="toggleCard(' + cap.id + ')">';
        html += '<span class="status-dot ' + (ok ? 'ok' : 'err') + '"></span>';
        html += '<div class="card-title">';
        html += '<span class="card-method ' + methodClass(cap.method) + '">' + escapeHtml(cap.method || '?') + '</span>';
        html += '<span class="card-host">' + escapeHtml(cap.hostname || '') + '</span>';
        html += '<span class="card-path">' + escapeHtml(cap.path || '') + '</span>';
        html += '</div>';
        html += '<div class="card-meta">';
        if (cap.model_name) html += '<span class="meta-pill model">' + escapeHtml(cap.model_name) + '</span>';
        html += '<span class="meta-pill time">' + fmtTime(cap.captured_at) + '</span>';
        html += '<span class="meta-pill size">' + (cap.duration_ms||0) + 'ms / ' + fmtSize(cap.content_length||0) + '</span>';
        html += '<span class="meta-pill" style="color:' + (ok?'#34d399':'#f87171') + '">' + (cap.status_code||'-') + '</span>';
        html += '</div>';
        html += '<span class="expand-icon" id="icon-' + cap.id + '">&#9654;</span>';
        html += '</div>';
        /* Detail (lazy) */
        html += '<div class="card-detail" id="' + detailId + '">';
        html += '<div class="loading" style="padding:16px;">加载详情...</div>';
        html += '</div>';
        html += '</div>';
    });
    container.innerHTML = html;
}

/* ---- Filter ---- */
function filterCaptures() {
    const q = (document.getElementById('searchInput').value || '').toLowerCase();
    if (!q) { renderList(allCaptures); return; }
    const filtered = allCaptures.filter(c =>
        (c.hostname||'').toLowerCase().includes(q) ||
        (c.path||'').toLowerCase().includes(q) ||
        (c.model_name||'').toLowerCase().includes(q) ||
        (c.method||'').toLowerCase().includes(q)
    );
    renderList(filtered);
}

/* ---- Toggle card ---- */
let loadedDetails = {};
async function toggleCard(id) {
    const panel = document.getElementById('detail-' + id);
    const icon = document.getElementById('icon-' + id);
    if (!panel) return;
    if (panel.classList.contains('open')) {
        panel.classList.remove('open');
        icon.classList.remove('open');
        return;
    }
    panel.classList.add('open');
    icon.classList.add('open');
    if (loadedDetails[id]) {
        panel.innerHTML = loadedDetails[id];
        initTabs(id);
        return;
    }
    try {
        const res = await fetch('/api/captures/' + id);
        const d = await res.json();
        if (d.error) { panel.innerHTML = '<div style="padding:12px;color:#f87171;">加载失败</div>'; return; }

        const reqText = (d.request_body_formatted || d.request_body || '').substring(0, 50000);
        const hasPretty = d.response_body_formatted && d.response_body_formatted !== d.response_body;
        const respPretty = d.response_body_formatted || '';
        const respRaw = d.response_body_raw || d.response_body || '';

        window._capTexts = window._capTexts || {};
        window._capTexts[id] = { req: d.request_body || '', resp: respPretty || respRaw || '' };

        let html = '';

        /* Tabs */
        html += '<div class="detail-tabs">';
        html += '<span class="detail-tab active" data-tab="req" onclick="switchTab(' + id + ',\'req\',event)">&#9654; 请求</span>';
        html += '<span class="detail-tab' + (hasPretty?'':'') + '" data-tab="resp" onclick="switchTab(' + id + ',\'resp\',event)">&#9664; 响应</span>';
        if (hasPretty) html += '<span class="detail-tab" data-tab="raw" onclick="switchTab(' + id + ',\'raw\',event)">原始</span>';
        html += '<span class="detail-tab" data-tab="meta" onclick="switchTab(' + id + ',\'meta\',event)">元数据</span>';
        html += '</div>';

        html += '<div class="detail-body">';

        /* Request panel */
        html += '<div class="content-panel active" data-panel="req">';
        const reqShort = reqText.length > 300;
        html += '<div class="code-block' + (reqShort?'':'') + '" id="req-block-' + id + '">';
        html += escapeHtml(reqShort ? reqText.substring(0, 300) + '\n...' : (reqText||'(空)'));
        html += '</div>';
        if (reqShort) {
            html += '<span class="toggle-expand" onclick="toggleBlock(\'req-block-' + id + '\',this,' + id + ',\'req\')">展开完整请求</span>';
        }
        html += ' <span class="toggle-expand" onclick="event.stopPropagation();downloadText(window._capTexts[' + id + '].req,\'prompt-' + id + '.txt\')">&#x2913; 导出</span>';
        html += '</div>';

        /* Response panel */
        html += '<div class="content-panel' + (hasPretty?'':' active') + '" data-panel="resp">';
        const respText = hasPretty ? respPretty : (d.response_body_formatted || d.response_body || '');
        const respShort = respText.length > 300;
        html += '<div class="code-block' + (respShort?'':'') + (hasPretty?' pretty':'') + '" id="resp-block-' + id + '">';
        html += escapeHtml(respShort ? respText.substring(0, 300) + '\n...' : respText);
        html += '</div>';
        if (respShort) {
            html += '<span class="toggle-expand" onclick="toggleBlock(\'resp-block-' + id + '\',this,' + id + ',\'resp\')">展开完整响应</span>';
        }
        html += ' <span class="toggle-expand" onclick="event.stopPropagation();downloadText(window._capTexts[' + id + '].resp,\'response-' + id + '.txt\')">&#x2913; 导出</span>';
        html += '</div>';

        /* Raw panel */
        if (hasPretty) {
            html += '<div class="content-panel" data-panel="raw">';
            html += '<div class="code-block" id="raw-block-' + id + '">' + escapeHtml(respRaw) + '</div>';
            html += '</div>';
        }

        /* Meta panel */
        html += '<div class="content-panel" data-panel="meta">';
        html += '<div class="meta-grid">';
        if (d.model_name) html += '<div class="meta-item"><span class="meta-key">模型</span><span class="meta-val">' + escapeHtml(d.model_name) + '</span></div>';
        html += '<div class="meta-item"><span class="meta-key">方法</span><span class="meta-val">' + escapeHtml(d.method||'') + '</span></div>';
        html += '<div class="meta-item"><span class="meta-key">路径</span><span class="meta-val">' + escapeHtml(d.path||'') + '</span></div>';
        html += '<div class="meta-item"><span class="meta-key">状态</span><span class="meta-val" style="color:' + (statusOk(d.status_code)?'#34d399':'#f87171') + '">' + (d.status_code||'-') + '</span></div>';
        html += '<div class="meta-item"><span class="meta-key">耗时</span><span class="meta-val">' + (d.duration_ms||0) + 'ms</span></div>';
        html += '<div class="meta-item"><span class="meta-key">大小</span><span class="meta-val">' + fmtSize(d.content_length||0) + '</span></div>';
        html += '<div class="meta-item"><span class="meta-key">时间</span><span class="meta-val">' + fmtTime(d.captured_at) + '</span></div>';
        html += '</div>';
        html += '</div>';

        html += '</div>'; /* detail-body */

        loadedDetails[id] = html;
        panel.innerHTML = html;
        initTabs(id);
    } catch(e) {
        panel.innerHTML = '<div style="padding:12px;color:#f87171;">加载失败：' + escapeHtml(e.message) + '</div>';
    }
}

function initTabs(id) {
    // tab switching is handled by switchTab via onclick
}

function switchTab(id, tab, event) {
    event.stopPropagation();
    const panel = document.getElementById('detail-' + id);
    if (!panel) return;
    panel.querySelectorAll('.detail-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
    panel.querySelectorAll('.content-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === tab));
}

function toggleBlock(blockId, linkEl, capId, type) {
    const el = document.getElementById(blockId);
    if (!el) return;
    const key = type === 'req' ? 'req' : 'resp';
    const fullText = window._capTexts && window._capTexts[capId] && window._capTexts[capId][key];
    if (!fullText) return;
    if (el.classList.contains('expanded')) {
        el.classList.remove('expanded');
        el.textContent = fullText.substring(0, 300) + '\n...';
        linkEl.textContent = '展开完整' + (type === 'req' ? '请求' : '响应');
    } else {
        el.classList.add('expanded');
        el.textContent = fullText;
        linkEl.textContent = '收起';
    }
}

/* ---- Export ---- */
async function exportCaptures() {
    const btn = document.getElementById('exportBtn');
    btn.disabled = true; btn.textContent = '导出中...';
    try {
        const res = await fetch('/api/captures/export');
        const blob = await res.blob();
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        const now = new Date();
        a.download = 'llm_captures_export_' + now.getFullYear() +
            String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0') + '_' +
            String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + '.json';
        document.body.appendChild(a); a.click();
        document.body.removeChild(a); URL.revokeObjectURL(url);
    } catch(e) { alert('导出失败：' + e.message); }
    finally { btn.disabled = false; btn.textContent = '\u2913 导出全部'; }
}

document.addEventListener('DOMContentLoaded', loadCaptures);
</script>
</body>
</html>
"""


# ========== Main ==========

def main():
    """Entry point for the console script (used by `wb-monitor` command)."""
    print("""
╔═══════════════════════════════════════════════════════╗
║              WorkBuddy Monitor v1.0                  ║
║   上下文内容 & Token 用量监控面板                       ║
╚═══════════════════════════════════════════════════════╝
    """)

    init_db()

    watcher = TraceWatcher()
    watcher.start()

    print(f"  Dashboard: http://{HOST}:{PORT}")
    print(f"  Trace dir: {TRACE_DIR}")
    print(f"  DB:        {DB_PATH}")
    print(f"  Auto-refresh every 15s\n")

    # Use threaded mode for development
    app.run(host=HOST, port=PORT, debug=False, use_reloader=False)


if __name__ == "__main__":
    main()
