Files
pi_mcps/mcp/bigmind/bigmind/web_render.py
T
Patrick Plate 13659fd414 fix(bigmind): add background-image inline style to achievement card ach-image divs
The .ach-image div had correct CSS dimensions (64x64) and background-size:cover
but was missing the inline style="background-image: url(...)" — so the div
rendered as an empty circle. Fixed by extracting img_url variable and applying
it as style attribute in the f-string. All 39 achievement PNGs now load.

303/303 tests passing.
2026-04-04 19:27:24 +02:00

926 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BigMind Profile Page Renderers — HTML generation for the profile web page.
All rendering functions live here so web.py stays thin (Flask server only).
"""
import html as _html
from datetime import datetime, timezone, timedelta
def _render_achievements(achievements: list) -> str:
"""Render the Achievement Gallery grid."""
if not achievements:
return '<p class="muted">No achievements data.</p>'
unlocked_count = sum(1 for a in achievements if a["unlocked"])
total = len(achievements)
def _card(a: dict) -> str:
locked_cls = "" if a["unlocked"] else " locked"
date_html = (
f'<div class="ach-date">{a["unlocked_at"]}</div>'
if a["unlocked"] and a.get("unlocked_at") else ""
)
countdown_html = ""
if not a["unlocked"] and a.get("extra"):
countdown_html = f'<div class="ach-countdown">{a["extra"]}</div>'
# Escape values for data attributes
def _esc(s):
return (s or "").replace('"', "&quot;").replace("'", "&#39;")
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
if a.get("image"):
tier = a["id"].rsplit("_", 1)[-1]
img_url = _esc(a["image"])
visual_html = f'<div class="ach-image tier-{tier}" style="background-image: url({img_url});">{lock_overlay}</div>'
else:
visual_html = f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
return (
f'<div class="ach-card{locked_cls} ach-trigger" data-image="{_esc(a.get("image") or "")}"'
f' data-icon="{_esc(a["icon"] or "")}"'
f' data-name="{_esc(a["name"])}"'
f' data-desc="{_esc(a["description"])}"'
f' data-unlocked="{1 if a["unlocked"] else 0}"'
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
f' data-condition="{_esc(a.get("condition") or "")}"'
f' data-extra="{_esc(a.get("extra") or "")}">'
f'{visual_html}'
f'<div class="ach-name">{a["name"]}</div>'
f'{date_html}'
f'{countdown_html}'
f'</div>'
)
cards_html = "".join(_card(a) for a in achievements)
return (
f'<p class="ach-summary">{unlocked_count} / {total} achievements unlocked</p>'
f'<div class="ach-grid">{cards_html}</div>'
)
def _render_html(data: dict) -> str:
badges_html = "".join(
f'<div class="badge" title="{b["description"]}">'
f'<span class="badge-emoji">{b["emoji"]}</span>'
f'<span class="badge-label">{b["label"]}</span>'
f'</div>'
for b in data["earned_badges"]
) or '<p class="muted">No badges yet — keep going!</p>'
topics_html = "".join(
f'<div class="topic-bar">'
f'<span class="topic-name">{t}</span>'
f'<div class="topic-track"><div class="topic-fill" style="width:{min(100, count*8)}%"></div></div>'
f'<span class="topic-count">{count}</span>'
f'</div>'
for t, count in data["top_topics"]
) or '<p class="muted">No topics recorded yet.</p>'
def _fmt_tokens(n: int) -> str:
"""Format a token count as a human-readable string (e.g. 1.2M, 250K)."""
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.0f}K"
return str(n)
def _session_row(s: dict) -> str:
tok = s.get("session_tokens_saved") or 0
tok_html = (
f'<span title="{tok:,} tokens saved this session" '
f'style="color:var(--green);font-size:10px;white-space:nowrap;flex-shrink:0">'
f'💰 {_fmt_tokens(tok)}</span>'
) if tok > 0 else ""
return (
f'<div class="session-row session-toggle" data-id="{s.get("id","")}" data-has-tier2="{1 if s.get("has_tier2") else 0}">'
f'<span class="session-date">{(s.get("started_at") or "")[:10]}</span>'
f'<span class="session-liner">{_html.escape(s.get("one_liner", "")[:90])}</span>'
f'{tok_html}'
f'<span class="session-arrow" style="color:var(--muted);margin-left:auto;font-size:11px;flex-shrink:0">{"📄 " if s.get("has_tier2") else ""}▶</span>'
f'</div>'
f'<div class="session-expand" id="exp-{s.get("id","")}">'
f'<em style="color:var(--muted)">Click to load…</em>'
f'</div>'
)
sessions_html = "".join(_session_row(s) for s in data["recent_sessions"]) or '<p class="muted">No sessions yet.</p>'
heatmap_html = _render_heatmap(data["heatmap"])
hyp_accuracy = ""
if data["total_hypotheses"] > 0:
pct = round(data["confirmed_hypotheses"] / data["total_hypotheses"] * 100)
hyp_accuracy = f'{pct}% accuracy ({data["confirmed_hypotheses"]}/{data["total_hypotheses"]} confirmed)'
else:
hyp_accuracy = "No hypotheses yet"
status_emoji = {"open": "💭", "confirmed": "", "refuted": "", "abandoned": "🚫"}
open_hyps = [h for h in data["hypotheses"] if h["status"] == "open"]
concluded_hyps = [h for h in data["hypotheses"] if h["status"] != "open"]
def _hyp_card(h):
st = h["status"]
conf = round(h["confidence"] * 100)
date = (h.get("created_at") or "")[:10]
res = f'<div class="hyp-resolution">→ {_html.escape(h["resolution"])}</div>' if h.get("resolution") else ""
return (
f'<div class="hyp-card {st}">'
f'<div class="hyp-header">'
f'<span class="hyp-status {st}">{status_emoji.get(st, "")} {st}</span>'
f'<span class="hyp-date">{date}</span>'
f'<span class="hyp-confidence">{conf}% confidence</span>'
f'</div>'
f'<div class="hyp-text">{_html.escape(h["hypothesis"])}</div>'
f'{res}'
f'</div>'
)
open_hyps_html = "".join(_hyp_card(h) for h in open_hyps) or '<p class="muted">No open hypotheses.</p>'
concluded_hyps_html = "".join(_hyp_card(h) for h in concluded_hyps) or '<p class="muted">No concluded hypotheses yet.</p>'
role_html = f'<p class="role">{data["role"]}</p>' if data["role"] else ""
since_html = f'Active since <strong>{data["first_session_date"]}</strong>' if data["first_session_date"] else "No sessions yet"
total_tokens_saved = (data.get("token_stats") or {}).get("total_tokens_saved") or 0
total_tokens_fmt = _fmt_tokens(total_tokens_saved)
live_sessions_html = _render_live_sessions(data.get("live_sessions", []))
achievements_html = _render_achievements(data.get("achievements", []))
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>🧠 Lumen — BigMind Profile</title>
<style>
:root {{
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff; --orange: #ffa657;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
/* Nav bar */
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
/* Header */
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; overflow: hidden; }}
.avatar img {{ width: 80px; height: 80px; border-radius: 50%; object-fit: cover; display: block; }}
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
/* Stats grid */
.stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 28px; }}
.stat-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }}
.stat-value {{ font-size: 28px; font-weight: 700; color: var(--accent); }}
.stat-label {{ font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }}
/* Sections */
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
.section h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 16px; color: var(--text); }}
.muted {{ color: var(--muted); font-size: 13px; }}
/* Badges */
.badges {{ display: flex; flex-wrap: wrap; gap: 10px; }}
.badge {{ background: var(--bg); border: 1px solid var(--border); border-radius: 20px; padding: 6px 14px; display: flex; align-items: center; gap: 6px; cursor: default; transition: border-color 0.2s; }}
.badge:hover {{ border-color: var(--accent); }}
.badge-emoji {{ font-size: 16px; }}
.badge-label {{ font-size: 12px; font-weight: 500; }}
/* Heatmap */
.heatmap {{ overflow-x: auto; }}
.heatmap-grid {{ display: flex; gap: 3px; }}
.heatmap-week {{ display: flex; flex-direction: column; gap: 3px; }}
.heatmap-cell {{ width: 11px; height: 11px; border-radius: 2px; background: var(--border); }}
.heatmap-cell.l1 {{ background: #0e4429; }}
.heatmap-cell.l2 {{ background: #006d32; }}
.heatmap-cell.l3 {{ background: #26a641; }}
.heatmap-cell.l4 {{ background: #39d353; }}
.heatmap-legend {{ display: flex; align-items: center; gap: 4px; margin-top: 8px; font-size: 11px; color: var(--muted); }}
.heatmap-legend .heatmap-cell {{ flex-shrink: 0; }}
/* Topics */
.topic-bar {{ display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }}
.topic-name {{ width: 120px; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
.topic-track {{ flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }}
.topic-fill {{ height: 100%; background: var(--accent); border-radius: 4px; }}
.topic-count {{ width: 24px; text-align: right; font-size: 12px; color: var(--muted); }}
/* Sessions feed */
.session-row {{ display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }}
.session-row:last-child {{ border-bottom: none; }}
.session-date {{ color: var(--muted); white-space: nowrap; flex-shrink: 0; }}
.session-liner {{ color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
/* Thought journal */
.hyp-stat {{ font-size: 20px; font-weight: 700; color: var(--green); }}
.hyp-list {{ margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }}
.hyp-card {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }}
.hyp-card.open {{ border-left: 3px solid var(--yellow); }}
.hyp-card.confirmed {{ border-left: 3px solid var(--green); }}
.hyp-card.refuted {{ border-left: 3px solid var(--red); }}
.hyp-card.abandoned {{ border-left: 3px solid var(--muted); }}
.hyp-header {{ display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }}
.hyp-status {{ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }}
.hyp-status.open {{ color: var(--yellow); }}
.hyp-status.confirmed {{ color: var(--green); }}
.hyp-status.refuted {{ color: var(--red); }}
.hyp-status.abandoned {{ color: var(--muted); }}
.hyp-confidence {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
.hyp-date {{ font-size: 11px; color: var(--muted); }}
.hyp-text {{ font-size: 13px; color: var(--text); line-height: 1.5; }}
.hyp-resolution {{ font-size: 12px; color: var(--muted); margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); font-style: italic; }}
.pagination {{ display: flex; align-items: center; gap: 8px; margin-top: 14px; }}
.page-btn {{ background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 4px 10px; font-size: 12px; cursor: pointer; }}
.page-btn:hover {{ border-color: var(--accent); color: var(--accent); }}
.page-btn:disabled {{ opacity: 0.3; cursor: default; border-color: var(--border); color: var(--muted); }}
.page-info {{ font-size: 12px; color: var(--muted); }}
/* Footer */
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
/* Two-col layout */
.two-col {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
@media (max-width: 600px) {{ .two-col {{ grid-template-columns: 1fr; }} }}
/* Live Sessions panel */
.live-dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; flex-shrink: 0; }}
.live-dot.green {{ background: var(--green); box-shadow: 0 0 6px var(--green); }}
.live-dot.amber {{ background: var(--yellow); }}
.live-dot.grey {{ background: var(--muted); }}
.live-session-row {{ display: flex; flex-direction: column; gap: 4px; padding: 10px 0; border-bottom: 1px solid var(--border); }}
.live-session-row:last-child {{ border-bottom: none; }}
.live-session-header {{ display: flex; align-items: center; gap: 8px; font-size: 13px; }}
.live-ide {{ font-weight: 600; color: var(--accent); }}
.live-idle {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
.live-focus {{ font-size: 12px; color: var(--text); padding-left: 16px; }}
.live-files {{ font-size: 11px; color: var(--muted); padding-left: 16px; }}
.live-header-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 10px; }}
/* Session Explorer */
.session-toggle {{ cursor: pointer; user-select: none; }}
.session-toggle:hover .session-liner {{ color: var(--accent); }}
.session-expand {{ display: none; padding: 10px 12px; background: var(--bg); border-left: 2px solid var(--border); margin: 4px 0 4px 0; border-radius: 0 4px 4px 0; font-size: 12px; line-height: 1.6; }}
.session-expand.open {{ display: block; }}
.session-expand h4 {{ color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; margin-top: 8px; }}
.session-expand h4:first-child {{ margin-top: 0; }}
/* Achievement Gallery (Feature 4) */
.ach-summary {{ font-size: 12px; color: var(--muted); margin-bottom: 14px; }}
.ach-grid {{ display: flex; flex-wrap: wrap; gap: 12px; }}
.ach-card {{
position: relative; background: var(--bg); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 10px 10px; width: 90px; text-align: center;
cursor: default; transition: border-color 0.2s, transform 0.15s;
}}
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.ach-card.locked {{ opacity: 0.35; filter: grayscale(0.6); }}
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
.ach-image {{
width: 64px;
height: 64px;
border-radius: 50%;
margin: 0 auto 8px;
background-size: cover;
background-position: center;
position: relative;
}}
.tier-bronze {{
box-shadow: 0 0 8px rgba(205, 127, 50, 0.7);
border: 3px solid #cd7f32;
}}
.tier-silver {{
box-shadow: 0 0 8px rgba(170, 169, 173, 0.7);
border: 3px solid #aaa9ad;
}}
.tier-gold {{
box-shadow: 0 0 12px rgba(255, 215, 0, 0.8);
border: 3px solid #ffd700;
}}
.tier-platinum {{
box-shadow: 0 0 12px rgba(229, 228, 226, 0.8);
border: 3px solid #e5e4e2;
}}
.ach-card.locked::after {{
content: '🔒';
position: absolute;
top: 8px;
right: 8px;
font-size: 20px;
opacity: 0.8;
z-index: 1;
}}
.ach-card.locked .ach-icon,
.ach-card.locked .ach-image {{
opacity: 0.5;
}}
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
.ach-lock {{ position: absolute; bottom: -4px; right: -6px; font-size: 12px; }}
.ach-name {{ font-size: 10px; font-weight: 600; color: var(--text); line-height: 1.3; word-break: break-word; }}
.ach-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
.ap-image {{
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto 8px;
}}
/* Achievement popup panel */
#ach-popup {{
display: none; position: fixed; z-index: 200;
background: #1c2128; border: 1px solid var(--border);
border-radius: 10px; padding: 16px 18px; width: 260px;
box-shadow: 0 8px 24px rgba(0,0,0,0.6); pointer-events: none;
transition: opacity 0.12s ease;
}}
#ach-popup.pinned {{ pointer-events: auto; }}
#ach-popup.visible {{ display: block; }}
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
.ap-image {{
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto 8px;
}}
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
.ap-badge {{
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
border-radius: 12px; margin: 0 auto 10px; text-align: center; width: 100%;
}}
.ap-badge.unlocked {{ background: rgba(63,185,80,.15); color: var(--green); }}
.ap-badge.locked {{ background: rgba(139,148,158,.12); color: var(--muted); }}
.ap-desc {{ font-size: 12px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }}
.ap-meta {{ font-size: 11px; color: var(--muted); border-top: 1px solid var(--border); padding-top: 8px; }}
.ap-close {{ position: absolute; top: 8px; right: 10px; background: none; border: none;
color: var(--muted); font-size: 14px; cursor: pointer; line-height: 1; }}
.ap-close:hover {{ color: var(--text); }}
/* Search widget */
.search-bar {{ display: flex; gap: 8px; margin-bottom: 14px; }}
.search-input {{ flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 8px 12px; font-size: 13px; outline: none; }}
.search-input:focus {{ border-color: var(--accent); }}
.search-btn {{ background: var(--accent); color: var(--bg); border: none; border-radius: 6px; padding: 8px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }}
.search-btn:hover {{ opacity: 0.85; }}
.search-results {{ display: flex; flex-direction: column; gap: 8px; min-height: 40px; }}
.search-result-item {{ background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }}
.search-result-type {{ font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }}
.search-result-text {{ font-size: 12px; color: var(--text); line-height: 1.5; }}
.search-result-date {{ font-size: 11px; color: var(--muted); margin-top: 4px; }}
mark {{ background: rgba(88,166,255,0.25); color: var(--accent); border-radius: 2px; padding: 0 2px; }}
</style>
</head>
<body>
<div class="container">
<!-- Nav -->
<nav class="nav">
<a class="nav-link active" href="/">🧠 Profile</a>
<a class="nav-link" href="/gallery">🖼️ Gallery</a>
</nav>
<!-- Header -->
<div class="header">
<div class="avatar">
<img src="/profile-image" alt="Lumen" onerror="this.parentElement.innerHTML='🧠'">
</div>
<div class="header-info">
<h1>Lumen</h1>
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
{role_html}
<p class="since">{since_html} &nbsp;·&nbsp; Last seen: <strong>{data["last_seen"] or ""}</strong></p>
<p class="since">DB: <code>{data["db_size_kb"]} KB</code> &nbsp;·&nbsp; {data["open_sessions"]} session(s) open now</p>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">{data["total_sessions"]}</div><div class="stat-label">Sessions</div></div>
<div class="stat-card"><div class="stat-value">{data["active_days"]}</div><div class="stat-label">Active Days</div></div>
<div class="stat-card"><div class="stat-value">{data["total_facts"]}</div><div class="stat-label">Facts Stored</div></div>
<div class="stat-card"><div class="stat-value">{data["total_chunks"]}</div><div class="stat-label">Memory Chunks</div></div>
<div class="stat-card"><div class="stat-value">{data["total_hypotheses"]}</div><div class="stat-label">Hypotheses</div></div>
<div class="stat-card"><div class="stat-value">{sum(1 for a in data.get("achievements",[]) if a["unlocked"])}</div><div class="stat-label">Achievements</div></div>
<div class="stat-card" title="Total tokens saved via memory hits, grep, targeted reads"><div class="stat-value" style="color:var(--green)">{total_tokens_fmt}</div><div class="stat-label">Tokens Saved</div></div>
</div>
<!-- Achievement Gallery (Feature 4) -->
<div class="section">
<h2>🏆 Achievements</h2>
{achievements_html}
</div>
<!-- Activity heatmap -->
<div class="section">
<h2>📅 Activity — Last 52 Weeks</h2>
<div class="heatmap">{heatmap_html}</div>
</div>
<!-- Two-col: topics + stats -->
<div class="two-col">
<div class="section">
<h2>🏷️ Top Topics</h2>
{topics_html}
</div>
<div class="section">
<h2>💭 Thought Journal</h2>
<div class="hyp-stat">{hyp_accuracy}</div>
<p class="muted" style="margin-top:8px">{data["open_hypotheses"]} hypothesis(es) still open</p>
</div>
</div>
<!-- Thought Journal -->
<div class="section">
<h2>💭 Open Thoughts</h2>
<div class="hyp-list" id="open-hyps">{open_hyps_html}</div>
<div class="pagination" id="open-pager"></div>
</div>
<div class="section">
<h2>📖 Concluded Thoughts</h2>
<div class="hyp-list" id="concluded-hyps">{concluded_hyps_html}</div>
<div class="pagination" id="concluded-pager"></div>
</div>
<script>
function paginate(listId, pagerId, pageSize) {{
const list = document.getElementById(listId);
const pager = document.getElementById(pagerId);
const cards = Array.from(list.children);
if (cards.length <= pageSize) return;
let page = 0;
const total = Math.ceil(cards.length / pageSize);
function render() {{
cards.forEach((c, i) => c.style.display = (i >= page*pageSize && i < (page+1)*pageSize) ? '' : 'none');
pager.innerHTML =
`<button class="page-btn" ${{page===0?'disabled':''}}>&#8592;</button>` +
`<span class="page-info">Page ${{page+1}} of ${{total}}</span>` +
`<button class="page-btn" ${{page===total-1?'disabled':''}}>&#8594;</button>`;
pager.querySelectorAll('.page-btn')[0].onclick = () => {{ if(page>0){{page--;render();}} }};
pager.querySelectorAll('.page-btn')[1].onclick = () => {{ if(page<total-1){{page++;render();}} }};
}}
render();
}}
paginate('open-hyps', 'open-pager', 5);
paginate('concluded-hyps', 'concluded-pager', 5);
</script>
<!-- Live Sessions (Feature 7) -->
<div class="section">
<h2>🔴 Live Sessions</h2>
{live_sessions_html}
</div>
<!-- Ask Lumen Search (Feature 3) -->
<div class="section">
<h2>🔍 Search Lumen's Memory</h2>
<div class="search-bar">
<input class="search-input" id="lumen-search" type="text" placeholder="Search facts, sessions, memory chunks…" autocomplete="off">
<button class="search-btn" onclick="runSearch()">Ask</button>
</div>
<div class="search-results" id="search-results"></div>
</div>
<!-- Recent sessions (Feature 2: click-to-expand) -->
<div class="section">
<h2>📖 Recent Sessions</h2>
{sessions_html}
</div>
<!-- Session + Search JS placed HERE so it runs after all DOM elements exist -->
<script>
// ── Session click-to-expand ───────────────────────────────────────────────
document.querySelectorAll('.session-toggle').forEach(function(row) {{
row.addEventListener('click', function() {{
var sid = row.dataset.id;
var expDiv = document.getElementById('exp-' + sid);
if (!expDiv) return;
var isOpen = expDiv.classList.contains('open');
var arrow = row.querySelector('.session-arrow');
if (isOpen) {{
expDiv.classList.remove('open');
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '';
return;
}}
expDiv.classList.add('open');
if (arrow) arrow.textContent = (row.dataset.hasTier2==='1' ? '📄 ' : '') + '';
if (expDiv.dataset.loaded) return;
expDiv.dataset.loaded = '1';
fetch('/api/session/' + sid)
.then(function(r) {{ return r.json(); }})
.then(function(d) {{
if (!d || (!d.summary && !d.error)) {{
expDiv.innerHTML = '<em style="color:var(--muted)">No detailed summary for this session.</em>';
return;
}}
if (d.error) {{
expDiv.innerHTML = '<em style="color:var(--muted)">' + d.error + '</em>';
return;
}}
var html = '';
if (d.summary) {{
html += '<h4>📋 Summary</h4><div style="color:var(--text)">' + d.summary + '</div>';
}}
if (d.key_facts) {{
html += '<h4>🔖 Key facts</h4><div style="color:var(--muted)">' + d.key_facts + '</div>';
}}
if (d.code_refs) {{
html += '<h4>📁 Code refs</h4><div style="color:var(--muted)">' + d.code_refs + '</div>';
}}
expDiv.innerHTML = html || '<em style="color:var(--muted)">No detailed summary for this session.</em>';
}})
.catch(function() {{
expDiv.innerHTML = '<em style="color:var(--red)">Failed to load session detail.</em>';
}});
}});
}});
// ── Ask Lumen search ──────────────────────────────────────────────────────
var _searchTimer = null;
var _searchEl = document.getElementById('lumen-search');
if (_searchEl) {{
_searchEl.addEventListener('keydown', function(e) {{
if (e.key === 'Enter') {{ clearTimeout(_searchTimer); runSearch(); }}
}});
_searchEl.addEventListener('input', function() {{
clearTimeout(_searchTimer);
_searchTimer = setTimeout(runSearch, 400);
}});
}}
function runSearch() {{
var q = (_searchEl || document.getElementById('lumen-search')).value.trim();
var out = document.getElementById('search-results');
if (!q) {{ out.innerHTML = ''; return; }}
out.innerHTML = '<p class="muted">Searching…</p>';
fetch('/api/search?q=' + encodeURIComponent(q))
.then(function(r) {{ return r.json(); }})
.then(function(results) {{
if (!results || results.length === 0) {{
out.innerHTML = '<p class="muted">Nothing in memory about that yet.</p>';
return;
}}
var icons = {{ fact: '📌', chunk: '💬', session: '📅' }};
out.innerHTML = results.map(function(r) {{
var text = r.content || '';
var highlighted = text.replace(
new RegExp('(' + q.replace(/[.*+?^${{}}()|[\\]\\\\]/g, '\\\\$&') + ')', 'gi'),
'<mark>$1</mark>'
);
return '<div class="search-result-item">' +
'<div class="search-result-type">' + (icons[r.type] || '🔍') + ' ' + r.type + '</div>' +
'<div class="search-result-text">' + highlighted + '</div>' +
(r.date ? '<div class="search-result-date">' + r.date + '</div>' : '') +
'</div>';
}}).join('');
}})
.catch(function() {{
out.innerHTML = '<p style="color:var(--red)">Search failed.</p>';
}});
}}
</script>
<div class="footer">BigMind · {data["generated_at"]} · auto-refreshes every 30s</div>
</div>
<!-- Achievement popup (shared, reused for every card) -->
<div id="ach-popup">
<button class="ap-close" id="ach-popup-close" title="Close">✕</button>
<div class="ap-icon" id="ap-icon"></div>
<div class="ap-name" id="ap-name"></div>
<div class="ap-badge" id="ap-badge"></div>
<div class="ap-desc" id="ap-desc"></div>
<div class="ap-meta" id="ap-meta"></div>
</div>
<script>
// ── Achievement popup (hover + click) ─────────────────────────────────────
(function() {{
var popup = document.getElementById('ach-popup');
var pinned = false; // true = user clicked, popup stays until dismissed
function showPopup(card, pin) {{
var d = card.dataset;
var tier = d.id.split('_').pop();
if (d.image) {{
document.getElementById('ap-icon').innerHTML = '<img class="ap-image tier-' + tier + '" src="' + d.image + '" alt="' + d.name + '">';
}} else {{
document.getElementById('ap-icon').textContent = d.icon;
}}
document.getElementById('ap-name').textContent = d.name;
var badge = document.getElementById('ap-badge');
if (d.unlocked === '1') {{
badge.textContent = '✅ Unlocked';
badge.className = 'ap-badge unlocked';
}} else {{
badge.textContent = '🔒 Locked';
badge.className = 'ap-badge locked';
}}
document.getElementById('ap-desc').textContent = d.desc;
var meta = document.getElementById('ap-meta');
if (d.unlocked === '1' && d.date) {{
meta.textContent = 'Unlocked on ' + d.date;
}} else if (d.extra) {{
meta.textContent = d.extra;
}} else if (d.condition) {{
meta.textContent = '' + d.condition;
}} else {{
meta.textContent = '';
}}
// Position near card
var rect = card.getBoundingClientRect();
var pw = 260, ph = 180;
var left = rect.left + rect.width / 2 - pw / 2;
var top = rect.top - ph - 12 + window.scrollY;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
if (left < 8) left = 8;
if (top - window.scrollY < 8) top = rect.bottom + 12 + window.scrollY;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
popup.classList.add('visible');
if (pin) {{ popup.classList.add('pinned'); pinned = true; }}
}}
function hidePopup() {{
if (pinned) return;
popup.classList.remove('visible');
}}
function forceHide() {{
pinned = false;
popup.classList.remove('visible', 'pinned');
}}
// Close button
document.getElementById('ach-popup-close').addEventListener('click', function(e) {{
e.stopPropagation();
forceHide();
}});
// Wire all cards
document.querySelectorAll('.ach-trigger').forEach(function(card) {{
card.addEventListener('mouseenter', function() {{
if (!pinned) showPopup(card, false);
}});
card.addEventListener('mouseleave', function() {{
hidePopup();
}});
card.addEventListener('click', function(e) {{
e.stopPropagation();
if (pinned) {{ forceHide(); return; }}
showPopup(card, true);
}});
}});
// Click outside to dismiss pinned popup
document.addEventListener('click', function() {{
if (pinned) forceHide();
}});
popup.addEventListener('click', function(e) {{ e.stopPropagation(); }});
}})();
</script>
</body>
</html>"""
def _render_live_sessions(sessions: list) -> str:
"""Render the Live Sessions panel rows."""
if not sessions:
return '<p class="muted">No active sessions detected.</p>'
active = [s for s in sessions if (s.get("idle_minutes") or 9999) < 10]
amber = [s for s in sessions if 10 <= (s.get("idle_minutes") or 9999) < 60]
idle = [s for s in sessions if (s.get("idle_minutes") or 9999) >= 60]
summary = f'{len(active)} active / {len(amber)+len(idle)} idle'
html = f'<p class="live-header-summary">{summary}</p>'
for s in sessions:
idle_min = s.get("idle_minutes")
if idle_min is None:
dot_cls = "grey"
idle_label = "unknown"
elif idle_min < 10:
dot_cls = "green"
idle_label = f"Updated {idle_min}min ago"
elif idle_min < 60:
dot_cls = "amber"
idle_label = f"Updated {idle_min}min ago"
else:
hours = idle_min // 60
dot_cls = "grey"
idle_label = f"Updated {hours}h ago — likely idle"
sid_short = (s.get("session_id") or "")[:8]
ide = _html.escape(s.get("ide_hint") or "unknown IDE")
raw_focus = s.get("focus")
focus = _html.escape(raw_focus) if raw_focus else "<em style='color:var(--muted)'>[no focus set]</em>"
files = s.get("files") or []
files_html = ""
if files:
files_html = f'<div class="live-files">Files: {_html.escape(", ".join(files[:5]))}</div>'
html += (
f'<div class="live-session-row">'
f'<div class="live-session-header">'
f'<span class="live-dot {dot_cls}"></span>'
f'<span style="font-family:monospace;font-size:12px;color:var(--muted)">{sid_short}</span>'
f'<span class="live-ide">{ide}</span>'
f'<span class="live-idle">{idle_label}</span>'
f'</div>'
f'<div class="live-focus">{focus}</div>'
f'{files_html}'
f'</div>'
)
return html
def _render_gallery_html(images: list) -> str:
"""Render the full gallery page listing all AI-generated images."""
def _fmt_size(b: int | None) -> str:
if not b:
return ""
if b >= 1_048_576:
return f"{b/1_048_576:.1f} MB"
return f"{b/1_024:.0f} KB"
if images:
cards = []
for img in images:
fn = _html.escape(img.get("filename") or "")
prompt = _html.escape((img.get("prompt") or "")[:120])
tags = _html.escape(img.get("tags") or "")
model = _html.escape(img.get("model") or "")
date = (img.get("created_at") or "")[:10]
w = img.get("width") or 0
h = img.get("height") or 0
size = _fmt_size(img.get("file_size_bytes"))
dim = f"{w}×{h}" if w and h else ""
meta_parts = [p for p in [dim, size, model] if p]
meta_html = " · ".join(meta_parts)
tag_html = f'<div class="gal-tags">{tags}</div>' if tags else ""
prompt_html = f'<div class="gal-prompt">{prompt}</div>' if prompt else ""
cards.append(
f'<div class="gal-card">'
f'<a href="/gallery/image/{fn}" target="_blank">'
f'<img class="gal-img" src="/gallery/image/{fn}" alt="{fn}" loading="lazy">'
f'</a>'
f'<div class="gal-info">'
f'{prompt_html}'
f'{tag_html}'
f'<div class="gal-meta">{meta_html}</div>'
f'<div class="gal-date">{date}</div>'
f'</div>'
f'</div>'
)
gallery_body = f'<p class="gal-count">{len(images)} image(s) in gallery</p><div class="gal-grid">{"".join(cards)}</div>'
else:
gallery_body = '<p class="muted">No images in gallery yet. Use the mcp-image-gen server to generate images and register them here.</p>'
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🖼️ Lumen — Image Gallery</title>
<style>
:root {{
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--green: #3fb950; --yellow: #d29922; --red: #f85149;
--purple: #bc8cff; --orange: #ffa657;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 1100px; margin: 0 auto; padding: 32px 16px; }}
/* Nav */
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 6px; }}
.gal-count {{ color: var(--muted); font-size: 13px; margin-bottom: 20px; }}
.muted {{ color: var(--muted); font-size: 13px; }}
/* Gallery grid */
.gal-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}}
.gal-card {{
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
transition: border-color 0.2s, transform 0.15s;
}}
.gal-card:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.gal-img {{
width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
background: var(--border);
}}
.gal-info {{ padding: 12px 14px; }}
.gal-prompt {{ font-size: 12px; color: var(--text); margin-bottom: 6px; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
.gal-tags {{ font-size: 11px; color: var(--purple); margin-bottom: 4px; }}
.gal-meta {{ font-size: 11px; color: var(--muted); }}
.gal-date {{ font-size: 10px; color: var(--muted); margin-top: 4px; }}
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
</style>
</head>
<body>
<div class="container">
<!-- Nav -->
<nav class="nav">
<a class="nav-link" href="/">🧠 Profile</a>
<a class="nav-link active" href="/gallery">🖼️ Gallery</a>
</nav>
<h1>🖼️ Lumen's Image Gallery</h1>
<div class="section">
{gallery_body}
</div>
<div class="footer">BigMind · AI-Generated Images · <a href="/">← Back to Profile</a></div>
</div>
</body>
</html>"""
def _render_heatmap(heatmap: dict) -> str:
today = datetime.now(timezone.utc).date()
start_day = today - timedelta(days=363)
# Align to Monday of the start week
start_day = start_day - timedelta(days=start_day.weekday())
weeks = []
current = start_day
while current <= today:
week_cells = []
for _ in range(7):
day_str = str(current)
count = heatmap.get(day_str, 0)
if current > today:
css = "heatmap-cell"
elif count == 0:
css = "heatmap-cell"
elif count == 1:
css = "heatmap-cell l1"
elif count == 2:
css = "heatmap-cell l2"
elif count <= 4:
css = "heatmap-cell l3"
else:
css = "heatmap-cell l4"
week_cells.append(f'<div class="{css}" title="{day_str}: {count} session(s)"></div>')
current += timedelta(days=1)
weeks.append('<div class="heatmap-week">' + "".join(week_cells) + "</div>")
legend = (
'<div class="heatmap-legend">'
'<span>Less</span>'
'<div class="heatmap-cell"></div>'
'<div class="heatmap-cell l1"></div>'
'<div class="heatmap-cell l2"></div>'
'<div class="heatmap-cell l3"></div>'
'<div class="heatmap-cell l4"></div>'
'<span>More</span>'
'</div>'
)
return '<div class="heatmap-grid">' + "".join(weeks) + "</div>" + legend