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
This commit is contained in:
Patrick Plate
2026-04-04 14:52:30 +02:00
parent a852e2ec0d
commit 67b8b44408
5 changed files with 231 additions and 9 deletions
+41 -1
View File
@@ -14,7 +14,7 @@ from typing import Generator
logger = logging.getLogger("BigMindDB") logger = logging.getLogger("BigMindDB")
SCHEMA_VERSION = 7 SCHEMA_VERSION = 8
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db" DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
# ─── DDL ───────────────────────────────────────────────────────────────────── # ─── DDL ─────────────────────────────────────────────────────────────────────
@@ -222,6 +222,22 @@ _DDL_STATEMENTS = [
notes, notes,
tokenize = 'porter unicode61' 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) _migrate_v5_to_v6(conn)
if current_version < 7: if current_version < 7:
_migrate_v6_to_v7(conn) _migrate_v6_to_v7(conn)
if current_version < 8:
_migrate_v7_to_v8(conn)
# Write / update the version # Write / update the version
if row: 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)") 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: def vacuum_db() -> None:
"""Run VACUUM outside of any transaction (SQLite requirement).""" """Run VACUUM outside of any transaction (SQLite requirement)."""
db_path = get_db_path() db_path = get_db_path()
+50 -2
View File
@@ -7,9 +7,10 @@ Serves a single live profile page built from the BigMind DB.
import os import os
import threading import threading
import logging import logging
from pathlib import Path
from datetime import datetime, timezone, timedelta 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") 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") _AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
_server_started = False _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 ───────────────────────────────────────────────────────────────── # ── Flask app ─────────────────────────────────────────────────────────────────
def _create_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 import memory_store
from bigmind.profile_builder import build_profile_data from bigmind.profile_builder import build_profile_data
from bigmind.db import db as _db
app = Flask(__name__) app = Flask(__name__)
app.logger.setLevel(logging.WARNING) # silence Flask request logs app.logger.setLevel(logging.WARNING) # silence Flask request logs
@@ -34,6 +49,39 @@ def _create_app():
data = build_profile_data(user["id"]) data = build_profile_data(user["id"])
return _render_html(data) 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>") @app.route("/api/session/<session_id>")
def api_session(session_id): def api_session(session_id):
"""Return Tier-2 summary JSON for a given session id.""" """Return Tier-2 summary JSON for a given session id."""
+135 -2
View File
@@ -162,9 +162,16 @@ def _render_html(data: dict) -> str:
a {{ color: var(--accent); text-decoration: none; }} a {{ color: var(--accent); text-decoration: none; }}
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }} .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 */
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }} .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; }} .header-info h1 {{ font-size: 24px; font-weight: 700; }}
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }} .role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }} .since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
@@ -322,9 +329,17 @@ def _render_html(data: dict) -> str:
<body> <body>
<div class="container"> <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 --> <!-- Header -->
<div class="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"> <div class="header-info">
<h1>Lumen</h1> <h1>Lumen</h1>
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p> <p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
@@ -671,6 +686,124 @@ def _render_live_sessions(sessions: list) -> str:
return html 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: def _render_heatmap(heatmap: dict) -> str:
today = datetime.now(timezone.utc).date() today = datetime.now(timezone.utc).date()
start_day = today - timedelta(days=363) start_day = today - timedelta(days=363)
+3 -2
View File
@@ -8,18 +8,19 @@ class TestDbInit:
def test_db_file_created(self, temp_db): def test_db_file_created(self, temp_db):
assert temp_db.exists() 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() conn = get_connection()
row = conn.execute("SELECT version FROM schema_version").fetchone() row = conn.execute("SELECT version FROM schema_version").fetchone()
conn.close() conn.close()
assert row is not None assert row is not None
assert row["version"] == 7 assert row["version"] == 8
def test_all_tables_exist(self, temp_db): def test_all_tables_exist(self, temp_db):
expected = { expected = {
"users", "identity_profile", "sessions", "users", "identity_profile", "sessions",
"session_summaries", "conversation_chunks", "facts", "session_summaries", "conversation_chunks", "facts",
"global_knowledge", "hypotheses", "upgrade_requests", "global_knowledge", "hypotheses", "upgrade_requests",
"gallery_images",
} }
conn = get_connection() conn = get_connection()
rows = conn.execute( rows = conn.execute(
@@ -201,12 +201,12 @@ class TestSchemaV6:
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
assert count == 0 # table exists, just empty 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: with db() as conn:
version = conn.execute( version = conn.execute(
"SELECT version FROM schema_version" "SELECT version FROM schema_version"
).fetchone()["version"] ).fetchone()["version"]
assert version == 7 assert version == 8
# ── Token Efficiency Tracker (Feature 6) ────────────────────────────────────── # ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────