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:
@@ -162,9 +162,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; }}
|
||||
@@ -322,9 +329,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>
|
||||
@@ -671,6 +686,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)
|
||||
|
||||
Reference in New Issue
Block a user