255 lines
9.7 KiB
Python
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}"
|