Files
pi_mcps/mcp/bigmind/bigmind/web.py
T

255 lines
9.7 KiB
Python

"""BigMind Profile Web Server — Flask app served on localhost:BIGMIND_PORT (default 7700).
Started automatically as a daemon thread when the MCP server starts.
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, _render_gallery_html # all HTML rendering lives there
logger = logging.getLogger("BigMindWeb")
_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, 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
@app.route("/")
def profile():
user = memory_store.get_or_create_user(memory_store.get_current_username())
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."""
detail = memory_store.get_session_detail(session_id)
if not detail:
return jsonify({"error": "No detailed summary for this session."})
return jsonify(detail)
@app.route("/api/search")
def api_search():
"""Unified memory search — facts + chunks + session one-liners."""
q = (request.args.get("q") or "").strip()
if not q:
return jsonify([])
user = memory_store.get_or_create_user(memory_store.get_current_username())
uid = user["id"]
results = []
# Facts
try:
facts = memory_store.search_facts(uid, q, limit=5)
for f in facts:
results.append({
"type": "fact",
"content": f"[{f.get('category','')}] {f.get('fact','')}",
"date": (f.get("created_at") or "")[:10],
"score": 3,
})
except Exception:
pass
# Chunks
try:
chunks = memory_store.search_chunks(uid, q, limit=5)
for c in chunks:
results.append({
"type": "chunk",
"content": (c.get("content") or "")[:300],
"date": (c.get("created_at") or "")[:10],
"score": 2,
})
except Exception:
pass
# Session one-liners (simple LIKE — no FTS needed)
try:
from bigmind.db import db as _db
with _db() as conn:
rows = conn.execute(
"""SELECT id, one_liner, started_at FROM sessions
WHERE user_id=? AND ended_at IS NOT NULL
AND one_liner LIKE ?
ORDER BY started_at DESC LIMIT 5""",
(uid, f"%{q}%"),
).fetchall()
for r in rows:
results.append({
"type": "session",
"content": r["one_liner"],
"date": (r.get("started_at") or "")[:10],
"score": 1,
})
except Exception:
pass
# Sort by type score desc, deduplicate by content
seen = set()
final = []
for r in sorted(results, key=lambda x: -x["score"]):
key = r["content"][:80]
if key not in seen:
seen.add(key)
final.append(r)
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
# ── Daemon thread startup ─────────────────────────────────────────────────────
def _port_in_use(port: int) -> bool:
"""Return True if something is already listening on 127.0.0.1:<port>."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2)
return s.connect_ex(("127.0.0.1", port)) == 0
def start_web_server() -> str:
"""Start the Flask profile server in a background daemon thread.
Safe to call multiple times (only starts once). If another BigMind
instance is already serving the port this process skips Flask startup
gracefully — the MCP tools still work, the profile page is served by
the other instance (same DB, same data).
The fix for the multi-IDE port-conflict bug: we check the port *before*
setting _server_started = True. Previously the flag was set immediately
after t.start(), so a failed Flask bind (Address already in use) left
_server_started = True with no Flask running — permanent lock-out.
"""
global _server_started
if _server_started:
return f"http://localhost:{_PORT}"
if _port_in_use(_PORT):
# Another BigMind process already owns the port — skip Flask startup.
# Don't set _server_started = True so if that process dies and this
# one is restarted, it can try again cleanly.
logger.info(
"BigMind profile server already running at http://localhost:%d "
"(another IDE instance). Skipping Flask startup.", _PORT
)
return f"http://localhost:{_PORT}"
app = _create_app()
_started_event = threading.Event()
_bind_failed = [] # non-empty if Flask couldn't bind
def _run():
import logging as _log
_log.getLogger("werkzeug").setLevel(_log.ERROR)
try:
_started_event.set() # signal that the thread is running
app.run(host="127.0.0.1", port=_PORT, debug=False, use_reloader=False)
except OSError as exc:
_bind_failed.append(str(exc))
logger.warning("BigMind web server failed to bind port %d: %s", _PORT, exc)
t = threading.Thread(target=_run, daemon=True, name="BigMindWebServer")
t.start()
_started_event.wait(timeout=2.0) # wait for thread to actually start
# Only mark as started if Flask didn't immediately report a bind error
if not _bind_failed:
_server_started = True
logger.info("BigMind profile server started at http://localhost:%d", _PORT)
if _AUTOOPEN:
import webbrowser, time
time.sleep(1.0)
webbrowser.open(f"http://localhost:{_PORT}")
else:
logger.warning(
"BigMind web server could not start (port %d in use). "
"Profile page unavailable from this IDE instance.", _PORT
)
return f"http://localhost:{_PORT}"
def get_profile_url() -> str:
return f"http://localhost:{_PORT}"