Compare commits

...

4 Commits

Author SHA1 Message Date
pplate dd244a8e6c feat(bigmind): add tiered AI-generated achievement badges with image rendering 2026-04-04 18:50:45 +02:00
Patrick Plate ee07dec4d3 Merge feat/bigmind/profile-image-gallery into main 2026-04-04 14:52:36 +02:00
Patrick Plate 67b8b44408 feat(bigmind): profile image + AI image gallery (schema v8)
- web.py: add /profile-image route (serves most-recent gallery PNG)
  add /gallery/image/<filename> route (per-image serving)
  add /gallery route (renders gallery page from DB)
  add _get_profile_image_path() helper
- web_render.py: replace emoji avatar with <img src=/profile-image>
  onerror fallback to 🧠 emoji
  add .nav bar with Profile/Gallery links to both pages
  add _render_gallery_html() full gallery page renderer
  add gallery CSS: .gal-grid, .gal-card, .gal-img, .gal-info, etc.
- db.py: bump SCHEMA_VERSION 7→8
  add gallery_images table (id, filename, prompt, tags, model,
    created_at, width, height, file_size_bytes)
  add _migrate_v7_to_v8() migration function
  add init_db() hook for v<8 migration
- tests: update test_schema_version_is_7→8 in test_db.py and
  test_feature7_live_sessions.py; add gallery_images to expected tables

Storage strategy: Option B (filesystem + DB metadata)
Images in ~/.mcp/bigmind/gallery/, metadata in SQLite
Pre-populated with 5 lumen_profiles images (seeds 2409122067,
764633840, 1367851518, 3135233944, 568659042)

Tests: 297/297 passing
2026-04-04 14:52:30 +02:00
Patrick Plate a852e2ec0d docs: merge Java wiki header images 2026-04-04 14:40:57 +02:00
46 changed files with 522 additions and 16 deletions
+41 -1
View File
@@ -14,7 +14,7 @@ from typing import Generator
logger = logging.getLogger("BigMindDB")
SCHEMA_VERSION = 7
SCHEMA_VERSION = 8
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
# ─── DDL ─────────────────────────────────────────────────────────────────────
@@ -222,6 +222,22 @@ _DDL_STATEMENTS = [
notes,
tokenize = 'porter unicode61'
)""",
# ── GALLERY IMAGES — AI-generated image archive ──────────────────────────
"""CREATE TABLE IF NOT EXISTS gallery_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
prompt TEXT,
tags TEXT,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
width INTEGER,
height INTEGER,
file_size_bytes INTEGER
)""",
"""CREATE INDEX IF NOT EXISTS idx_gallery_created
ON gallery_images(created_at DESC)""",
]
@@ -407,6 +423,8 @@ def init_db() -> None:
_migrate_v5_to_v6(conn)
if current_version < 7:
_migrate_v6_to_v7(conn)
if current_version < 8:
_migrate_v7_to_v8(conn)
# Write / update the version
if row:
@@ -457,6 +475,28 @@ def _migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
logger.info("BigMind schema migrated v6 → v7 (people/contacts directory)")
def _migrate_v7_to_v8(conn: sqlite3.Connection) -> None:
"""v7 → v8: add gallery_images table for AI-generated image archive."""
conn.execute("""
CREATE TABLE IF NOT EXISTS gallery_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
prompt TEXT,
tags TEXT,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
width INTEGER,
height INTEGER,
file_size_bytes INTEGER
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_gallery_created
ON gallery_images(created_at DESC)
""")
logger.info("BigMind schema migrated v7 → v8 (gallery_images table)")
def vacuum_db() -> None:
"""Run VACUUM outside of any transaction (SQLite requirement)."""
db_path = get_db_path()
+134 -2
View File
@@ -435,10 +435,10 @@ def compute_achievements(user_id: str) -> list[dict]:
# ── Assemble ──────────────────────────────────────────────────────────────
A = []
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None, image=None):
A.append(dict(id=id_, icon=icon, name=name, description=desc,
unlocked=unlocked, unlocked_at=unlocked_at,
condition=condition, extra=extra))
condition=condition, extra=extra, image=image))
_add("first_breath", "🌱", "First Breath",
"Opened the very first session",
@@ -539,6 +539,138 @@ def compute_achievements(user_id: str) -> list[dict]:
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
"Save 500,000+ tokens in a single operation")
# ── Tiered Achievement Badges (20 PNG) ────────────────────────────────────
# NOTE: conn is already closed above; open a fresh connection for tiered queries
tiers = ["bronze", "silver", "gold", "platinum"]
tier_names = ["Bronze", "Silver", "Gold", "Platinum"]
with db() as conn2:
# Networker (people directory)
try:
people_count = conn2.execute(
"SELECT COUNT(*) FROM people WHERE user_id=?", (user_id,)
).fetchone()[0]
except Exception:
people_count = 0
for i, thresh in enumerate([1, 5, 25, 100]):
unlocked = people_count >= thresh
unlocked_at = None
if unlocked:
try:
row = conn2.execute(
"SELECT created_at FROM people WHERE user_id=?"
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
except Exception:
row = None
unlocked_at = _dt(row[0]) if row else None
_add(
f"networker_{tiers[i]}", None, f"Networker {tier_names[i]}",
f"Added your {thresh:,}+ person to the directory",
unlocked, unlocked_at,
f"Reach {thresh:,} people (now: {people_count:,})",
image=f"static/achievements/networker_{tiers[i]}.png"
)
# Token Sniper (max single token save)
try:
max_token = conn2.execute(
"SELECT COALESCE(MAX(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
(user_id,)
).fetchone()[0]
except Exception:
max_token = 0
for i, thresh in enumerate([10000, 50000, 250000, 1000000]):
unlocked = max_token >= thresh
unlocked_at = None
if unlocked:
try:
row = conn2.execute(
"SELECT created_at FROM token_saves"
" WHERE user_id=? AND tokens_saved_estimate >= ?"
" ORDER BY created_at ASC LIMIT 1",
(user_id, thresh)
).fetchone()
except Exception:
row = None
unlocked_at = _dt(row[0]) if row else None
_add(
f"tokensniper_{tiers[i]}", None, f"Token Sniper {tier_names[i]}",
f"Single shot saved {thresh:,}+ tokens",
unlocked, unlocked_at,
f"Max single save {thresh:,}+ (current max: {max_token:,})",
image=f"static/achievements/tokensniper_{tiers[i]}.png"
)
# Hypothesis Master (confirmed hypotheses)
try:
confirmed_hyp_count = conn2.execute(
"SELECT COUNT(*) FROM hypotheses WHERE user_id=? AND status='confirmed'",
(user_id,)
).fetchone()[0]
except Exception:
confirmed_hyp_count = 0
for i, thresh in enumerate([3, 10, 25, 100]):
unlocked = confirmed_hyp_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT resolved_at FROM hypotheses"
" WHERE user_id=? AND status='confirmed'"
" ORDER BY resolved_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"hypothesismaster_{tiers[i]}", None, f"Hypothesis Master {tier_names[i]}",
f"Confirmed {thresh:,}+ predictions right",
unlocked, unlocked_at,
f"Confirm {thresh:,}+ hypotheses (now: {confirmed_hyp_count:,})",
image=f"static/achievements/hypothesismaster_{tiers[i]}.png"
)
# Memory Architect (facts stored — fact_count already computed above)
for i, thresh in enumerate([25, 100, 500, 2500]):
unlocked = fact_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT created_at FROM facts"
" WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)"
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"memoryarchitect_{tiers[i]}", None, f"Memory Architect {tier_names[i]}",
f"Stored {thresh:,}+ facts in your brain",
unlocked, unlocked_at,
f"Store {thresh:,}+ facts (now: {fact_count:,})",
image=f"static/achievements/memoryarchitect_{tiers[i]}.png"
)
# Session Veteran (session_count already computed above)
for i, thresh in enumerate([50, 250, 1000, 5000]):
unlocked = session_count >= thresh
unlocked_at = None
if unlocked:
row = conn2.execute(
"SELECT started_at FROM sessions"
" WHERE user_id=? AND ended_at IS NOT NULL"
" ORDER BY started_at ASC LIMIT 1 OFFSET ?",
(user_id, thresh - 1)
).fetchone()
unlocked_at = _dt(row[0]) if row else None
_add(
f"sessionveteran_{tiers[i]}", None, f"Session Veteran {tier_names[i]}",
f"Completed {thresh:,}+ sessions",
unlocked, unlocked_at,
f"Complete {thresh:,}+ sessions (now: {session_count:,})",
image=f"static/achievements/sessionveteran_{tiers[i]}.png"
)
return A
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+66 -2
View File
@@ -7,9 +7,10 @@ Serves a single live profile page built from the BigMind DB.
import os
import threading
import logging
from pathlib import Path
from datetime import datetime, timezone, timedelta
from bigmind.web_render import _render_html # all HTML rendering lives there
from bigmind.web_render import _render_html, _render_gallery_html # all HTML rendering lives there
logger = logging.getLogger("BigMindWeb")
@@ -17,13 +18,27 @@ _PORT = int(os.environ.get("BIGMIND_PORT", "7700"))
_AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
_server_started = False
# Gallery directory — images served from here
_GALLERY_DIR = Path(os.environ.get("BIGMIND_GALLERY_DIR", Path.home() / ".mcp" / "bigmind" / "gallery"))
# Profile image — last entry in gallery dir wins; fallback to original lumen-profile.png
def _get_profile_image_path() -> Path | None:
"""Return the path of the current profile image, or None if not found."""
# 1. Check gallery dir for lumen_profile* images (seed 568659042 = lumen_profile)
if _GALLERY_DIR.exists():
candidates = sorted(_GALLERY_DIR.glob("*.png"), reverse=True)
if candidates:
return candidates[0] # most recently named = most recent timestamp
return None
# ── Flask app ─────────────────────────────────────────────────────────────────
def _create_app():
from flask import Flask, jsonify, request
from flask import Flask, jsonify, request, send_file, abort
from bigmind import memory_store
from bigmind.profile_builder import build_profile_data
from bigmind.db import db as _db
app = Flask(__name__)
app.logger.setLevel(logging.WARNING) # silence Flask request logs
@@ -34,6 +49,39 @@ def _create_app():
data = build_profile_data(user["id"])
return _render_html(data)
@app.route("/profile-image")
def profile_image():
"""Serve the current Lumen profile picture."""
img_path = _get_profile_image_path()
if img_path and img_path.exists():
return send_file(str(img_path), mimetype="image/png")
abort(404)
@app.route("/gallery/image/<filename>")
def gallery_image(filename: str):
"""Serve a specific gallery image by filename."""
# Security: only allow alphanumeric + underscores + dots, no path traversal
safe_name = Path(filename).name
img_path = _GALLERY_DIR / safe_name
if img_path.exists() and img_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
mimetype = "image/png" if img_path.suffix.lower() == ".png" else "image/jpeg"
return send_file(str(img_path), mimetype=mimetype)
abort(404)
@app.route("/gallery")
def gallery():
"""Render the AI-generated image gallery page."""
_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
with _db() as conn:
rows = conn.execute(
"""SELECT id, filename, prompt, tags, model, created_at,
width, height, file_size_bytes
FROM gallery_images
ORDER BY created_at DESC"""
).fetchall()
images = [dict(r) for r in rows]
return _render_gallery_html(images)
@app.route("/api/session/<session_id>")
def api_session(session_id):
"""Return Tier-2 summary JSON for a given session id."""
@@ -111,6 +159,22 @@ def _create_app():
return jsonify(final[:15])
@app.route('/static/achievements/<filename>')
def achievements_image(filename: str):
from pathlib import Path
safe_name = Path(filename).name
img_path = Path('static') / 'achievements' / safe_name
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
mimetype = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
}.get(img_path.suffix.lower(), 'image/png')
return send_file(str(img_path), mimetype=mimetype)
abort(404)
return app
+214 -7
View File
@@ -29,18 +29,24 @@ def _render_achievements(achievements: list) -> str:
def _esc(s):
return (s or "").replace('"', "&quot;").replace("'", "&#39;")
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
if a.get("image"):
tier = a["id"].rsplit("_", 1)[-1]
visual_html = f'<div class="ach-image tier-{tier}">{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"'
f' data-icon="{_esc(a["icon"])}"'
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'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
f'{visual_html}'
f'<div class="ach-name">{a["name"]}</div>'
f'{date_html}'
f'{countdown_html}'
@@ -162,9 +168,16 @@ def _render_html(data: dict) -> str:
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; }}
.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; }}
@@ -276,11 +289,65 @@ def _render_html(data: dict) -> str:
.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;
@@ -292,6 +359,15 @@ def _render_html(data: dict) -> str:
#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;
@@ -322,9 +398,17 @@ def _render_html(data: dict) -> str:
<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">🧠</div>
<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>
@@ -542,7 +626,12 @@ def _render_html(data: dict) -> str:
function showPopup(card, pin) {{
var d = card.dataset;
document.getElementById('ap-icon').textContent = d.icon;
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') {{
@@ -671,6 +760,124 @@ def _render_live_sessions(sessions: list) -> str:
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)
+3 -2
View File
@@ -8,18 +8,19 @@ class TestDbInit:
def test_db_file_created(self, temp_db):
assert temp_db.exists()
def test_schema_version_is_7(self, temp_db):
def test_schema_version_is_8(self, temp_db):
conn = get_connection()
row = conn.execute("SELECT version FROM schema_version").fetchone()
conn.close()
assert row is not None
assert row["version"] == 7
assert row["version"] == 8
def test_all_tables_exist(self, temp_db):
expected = {
"users", "identity_profile", "sessions",
"session_summaries", "conversation_chunks", "facts",
"global_knowledge", "hypotheses", "upgrade_requests",
"gallery_images",
}
conn = get_connection()
rows = conn.execute(
@@ -201,12 +201,12 @@ class TestSchemaV6:
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
assert count == 0 # table exists, just empty
def test_schema_version_is_7(self, temp_db):
def test_schema_version_is_8(self, temp_db):
with db() as conn:
version = conn.execute(
"SELECT version FROM schema_version"
).fetchone()["version"]
assert version == 7
assert version == 8
# ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────
+62
View File
@@ -7,6 +7,7 @@ BIGMIND_DB_PATH + BIGMIND_USER to a fresh SQLite file per test.
import pytest
from datetime import datetime, timezone, timedelta
from bigmind import memory_store
from bigmind.db import db
from bigmind.profile_builder import compute_achievements, build_profile_data
@@ -44,6 +45,11 @@ class TestComputeAchievements:
"on_fire", "storyteller", "night_owl", "speed_thinker",
"first_handshake", "birthday", "shared_mind",
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
"networker_bronze", "networker_silver", "networker_gold", "networker_platinum",
"tokensniper_bronze", "tokensniper_silver", "tokensniper_gold", "tokensniper_platinum",
"hypothesismaster_bronze", "hypothesismaster_silver", "hypothesismaster_gold", "hypothesismaster_platinum",
"memoryarchitect_bronze", "memoryarchitect_silver", "memoryarchitect_gold", "memoryarchitect_platinum",
"sessionveteran_bronze", "sessionveteran_silver", "sessionveteran_gold", "sessionveteran_platinum",
}
assert expected == ids
@@ -325,4 +331,60 @@ class TestComputeAchievements:
# At minimum: first_breath + first_handshake = 2
assert len(unlocked) >= 2
class TestTieredAchievements:
def test_networker_bronze(self):
uid = _uid()
with db() as conn:
conn.execute("INSERT INTO people (user_id, username) VALUES (?, ?)", (uid, "test"))
conn.commit()
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'networker_bronze')
assert bronze['unlocked'] is True
assert bronze['image'].endswith('networker_bronze.png')
def test_tokensniper_silver(self):
uid = _uid()
sid = memory_store.create_session(uid)
memory_store.log_token_save(sid, uid, "big save", 60000, "grep")
achs = compute_achievements(uid)
silver = next(a for a in achs if a['id'] == 'tokensniper_silver')
assert silver['unlocked'] is True
def test_hypothesismaster_bronze(self):
uid = _uid()
sid = memory_store.create_session(uid)
for _ in range(3):
hid = memory_store.add_hypothesis(uid, sid, "test", 0.8)
memory_store.resolve_hypothesis(hid, uid, "confirmed", "yes")
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'hypothesismaster_bronze')
assert bronze['unlocked'] is True
def test_memoryarchitect_silver(self):
uid = _uid()
for _ in range(100):
memory_store.store_fact(uid, "test", f"fact {_}")
achs = compute_achievements(uid)
silver = next(a for a in achs if a['id'] == 'memoryarchitect_silver')
assert silver['unlocked'] is True
def test_sessionveteran_bronze(self):
uid = _uid()
for _ in range(50):
sid = memory_store.create_session(uid)
_close_session(sid)
achs = compute_achievements(uid)
bronze = next(a for a in achs if a['id'] == 'sessionveteran_bronze')
assert bronze['unlocked'] is True
def test_tiered_achievements_have_image(self):
uid = _uid()
achs = compute_achievements(uid)
tiered_ids = [
f"{cat}_{tier}" for cat in ["networker", "tokensniper", "hypothesismaster", "memoryarchitect", "sessionveteran"]
for tier in ["bronze", "silver", "gold", "platinum"]
]
for tid in tiered_ids:
a = next(aa for aa in achs if aa['id'] == tid)
assert a['image'] is not None
assert a['image'].endswith(tid + '.png')