Merge feat/bigmind/profile-image-gallery into main
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) ──────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user