553c38200a
- Dockerfile: build fable-mcp wheel into /app/dist/ during image build - routes/fable_mcp_dist.py: GET /api/fable-mcp/info + /download endpoints - app.py: register fable_mcp_dist_bp - fable_mcp/tools/admin.py: get_app_logs() hitting /api/admin/logs - fable_mcp/server.py: fable_get_app_logs MCP tool - SettingsView: "Fable MCP" section in API Keys tab with download button and install instructions - client.ts: getFableMcpInfo() helper - ci.yml: add fable-mcp/** to trigger paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
325 lines
13 KiB
Python
325 lines
13 KiB
Python
import logging
|
|
import time
|
|
import traceback as tb_module
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
|
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.routes.admin import admin_bp
|
|
from fabledassistant.routes.api import api
|
|
from fabledassistant.routes.auth import auth_bp
|
|
from fabledassistant.routes.briefing import briefing_bp
|
|
from fabledassistant.routes.chat import chat_bp
|
|
from fabledassistant.routes.export import export_bp
|
|
from fabledassistant.routes.notes import notes_bp
|
|
from fabledassistant.routes.images import images_bp
|
|
from fabledassistant.routes.milestones import milestones_bp
|
|
from fabledassistant.routes.task_logs import task_logs_bp
|
|
from fabledassistant.routes.projects import projects_bp
|
|
from fabledassistant.routes.push import push_bp
|
|
from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp
|
|
from fabledassistant.routes.quick_capture import quick_capture_bp
|
|
from fabledassistant.routes.settings import settings_bp
|
|
from fabledassistant.routes.tasks import tasks_bp
|
|
from fabledassistant.routes.groups import groups_bp
|
|
from fabledassistant.routes.shares import shares_bp
|
|
from fabledassistant.routes.in_app_notifications import notifications_bp
|
|
from fabledassistant.routes.users import users_bp
|
|
from fabledassistant.routes.api_keys import api_keys_bp
|
|
from fabledassistant.routes.search import search_bp
|
|
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_app() -> Quart:
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
|
|
)
|
|
|
|
# Validate config early so misconfigurations surface immediately with clear messages
|
|
try:
|
|
Config.validate()
|
|
except ValueError as exc:
|
|
logging.getLogger(__name__).error("Invalid configuration:\n%s", exc)
|
|
raise
|
|
|
|
app = Quart(__name__, static_folder=None)
|
|
app.secret_key = Config.SECRET_KEY
|
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|
app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES
|
|
|
|
if Config.SECRET_KEY == "dev-secret-change-me":
|
|
logger.warning(
|
|
"SECRET_KEY is set to the default value — session cookies are insecure. "
|
|
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
|
|
)
|
|
|
|
app.register_blueprint(admin_bp)
|
|
app.register_blueprint(api)
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(briefing_bp)
|
|
app.register_blueprint(chat_bp)
|
|
app.register_blueprint(export_bp)
|
|
app.register_blueprint(images_bp)
|
|
app.register_blueprint(milestones_bp)
|
|
app.register_blueprint(notes_bp)
|
|
app.register_blueprint(fable_mcp_dist_bp)
|
|
app.register_blueprint(projects_bp)
|
|
app.register_blueprint(push_bp)
|
|
app.register_blueprint(quick_capture_bp)
|
|
app.register_blueprint(settings_bp)
|
|
app.register_blueprint(task_logs_bp)
|
|
app.register_blueprint(tasks_bp)
|
|
app.register_blueprint(groups_bp)
|
|
app.register_blueprint(shares_bp)
|
|
app.register_blueprint(notifications_bp)
|
|
app.register_blueprint(users_bp)
|
|
app.register_blueprint(api_keys_bp)
|
|
app.register_blueprint(search_bp)
|
|
|
|
@app.before_request
|
|
async def before_request():
|
|
g.request_start = time.monotonic()
|
|
|
|
@app.after_request
|
|
async def after_request(response):
|
|
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
|
|
duration_ms = round(duration * 1000, 1)
|
|
# Downgrade noisy high-frequency / static paths to DEBUG
|
|
_quiet = (
|
|
request.path in {"/api/health", "/api/chat/status"}
|
|
or request.path.startswith(("/static/", "/assets/", "/sw.js", "/manifest.json"))
|
|
)
|
|
log_fn = logger.debug if _quiet else logger.info
|
|
log_fn(
|
|
"%s %s %s %.1fms",
|
|
request.method,
|
|
request.path,
|
|
response.status_code,
|
|
duration_ms,
|
|
)
|
|
|
|
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
|
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
|
try:
|
|
from fabledassistant.services.logging import log_usage
|
|
|
|
user = getattr(g, "user", None)
|
|
await log_usage(
|
|
user_id=user.id if user else None,
|
|
username=user.username if user else None,
|
|
endpoint=request.path,
|
|
method=request.method,
|
|
status_code=response.status_code,
|
|
duration_ms=duration_ms,
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to log usage", exc_info=True)
|
|
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
response.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
|
|
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
|
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
|
|
"base-uri 'self'; worker-src 'self';"
|
|
)
|
|
|
|
return response
|
|
|
|
@app.before_serving
|
|
async def startup():
|
|
import asyncio
|
|
|
|
from fabledassistant.services.embeddings import backfill_note_embeddings
|
|
from fabledassistant.services.generation_buffer import start_cleanup_loop
|
|
from fabledassistant.services.llm import ensure_model
|
|
from fabledassistant.services.logging import start_log_retention_loop
|
|
from fabledassistant.services.notifications import start_notification_loop
|
|
from fabledassistant.services.push import ensure_vapid_keys
|
|
|
|
start_cleanup_loop()
|
|
start_log_retention_loop()
|
|
start_notification_loop()
|
|
ensure_vapid_keys()
|
|
|
|
async def _warm_model(model: str) -> None:
|
|
"""Warm an already-installed model into VRAM (no pull)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
await client.post(
|
|
f"{Config.OLLAMA_URL}/api/generate",
|
|
json={"model": model, "prompt": "", "keep_alive": "30m"},
|
|
)
|
|
logger.info("Warmed model '%s' into VRAM", model)
|
|
except Exception:
|
|
logger.warning("Failed to warm model '%s'", model, exc_info=True)
|
|
|
|
async def _warm_user_models() -> None:
|
|
"""
|
|
Warm whichever chat model(s) users have selected in Settings.
|
|
|
|
Only warms models that are already installed in Ollama — never auto-pulls.
|
|
Falls back silently if no user preferences exist or Ollama is unreachable.
|
|
"""
|
|
from sqlalchemy import select as sa_select, distinct
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.setting import Setting
|
|
|
|
# 1. Collect all distinct default_model values users have saved.
|
|
try:
|
|
async with async_session() as session:
|
|
rows = await session.execute(
|
|
sa_select(distinct(Setting.value)).where(
|
|
Setting.key == "default_model",
|
|
Setting.value.isnot(None),
|
|
Setting.value != "",
|
|
)
|
|
)
|
|
user_models: set[str] = {r for (r,) in rows}
|
|
except Exception:
|
|
logger.debug("Could not read user model preferences from DB", exc_info=True)
|
|
return
|
|
|
|
if not user_models:
|
|
logger.debug("No user model preferences found; skipping warm-up")
|
|
return
|
|
|
|
# 2. Ask Ollama which models are currently installed.
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
|
resp.raise_for_status()
|
|
installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
|
|
except Exception:
|
|
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
|
|
return
|
|
|
|
# 3. Warm only the intersection (installed AND user-preferred).
|
|
for model in user_models:
|
|
base = model.removesuffix(":latest")
|
|
if model in installed or f"{base}:latest" in installed or base in installed:
|
|
await _warm_model(model)
|
|
else:
|
|
logger.info(
|
|
"User-preferred model '%s' is not installed; skipping warm-up "
|
|
"(install it via Settings → Models to enable auto-warm)",
|
|
model,
|
|
)
|
|
|
|
async def _pull_model(model: str, warm: bool = False) -> None:
|
|
try:
|
|
await ensure_model(model)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to ensure model '%s'",
|
|
model,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
if warm:
|
|
await _warm_model(model)
|
|
|
|
# Warm user-preferred chat models that are already installed.
|
|
# Also ensure the embedding model is pulled (no warm needed).
|
|
asyncio.create_task(_warm_user_models())
|
|
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
|
|
|
|
# After models are pulled, backfill embeddings for existing notes.
|
|
# Runs in the background so it never blocks the server from accepting requests.
|
|
async def _delayed_backfill() -> None:
|
|
await asyncio.sleep(30) # Give Ollama time to load the embedding model
|
|
try:
|
|
await backfill_note_embeddings()
|
|
except Exception:
|
|
logger.warning("Embedding backfill failed", exc_info=True)
|
|
|
|
asyncio.create_task(_delayed_backfill())
|
|
|
|
# Start briefing scheduler
|
|
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
|
await start_briefing_scheduler(asyncio.get_running_loop())
|
|
|
|
@app.after_serving
|
|
async def shutdown():
|
|
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
|
|
stop_briefing_scheduler()
|
|
|
|
@app.route("/")
|
|
async def serve_index():
|
|
resp = await make_response(
|
|
await send_from_directory(STATIC_DIR, "index.html")
|
|
)
|
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
return resp
|
|
|
|
# File extensions that belong to the SPA or its assets.
|
|
# Anything else with an extension is not a valid app path and gets a hard 404.
|
|
_SPA_EXTENSIONS = {
|
|
"", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
|
|
".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json",
|
|
}
|
|
|
|
@app.errorhandler(404)
|
|
async def handle_404(error):
|
|
# Return JSON 404 for API routes
|
|
if request.path.startswith("/api/"):
|
|
return jsonify({"error": "Not found"}), 404
|
|
|
|
# Try to serve a real static file first
|
|
path = request.path.lstrip("/")
|
|
file_path = STATIC_DIR / path
|
|
if path and file_path.is_file():
|
|
return await send_from_directory(STATIC_DIR, path)
|
|
|
|
# Reject paths with file extensions the app doesn't serve.
|
|
# This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s
|
|
# instead of serving them the Vue SPA with a misleading 200.
|
|
from pathlib import PurePosixPath
|
|
suffix = PurePosixPath(request.path).suffix.lower()
|
|
if suffix not in _SPA_EXTENSIONS:
|
|
return "", 404
|
|
|
|
# SPA fallback for clean client-side routes (/notes/123, /chat, etc.)
|
|
resp = await make_response(
|
|
await send_from_directory(STATIC_DIR, "index.html")
|
|
)
|
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
return resp
|
|
|
|
@app.errorhandler(500)
|
|
async def handle_500(error):
|
|
logger.exception("Internal server error on %s %s", request.method, request.path)
|
|
|
|
try:
|
|
from fabledassistant.services.logging import log_error
|
|
|
|
user = getattr(g, "user", None)
|
|
await log_error(
|
|
user_id=user.id if user else None,
|
|
username=user.username if user else None,
|
|
endpoint=request.path,
|
|
method=request.method,
|
|
error_type=type(error).__name__,
|
|
error_message=str(error),
|
|
traceback=tb_module.format_exc(),
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to log error", exc_info=True)
|
|
|
|
if request.path.startswith("/api/"):
|
|
return jsonify({"error": "Internal server error"}), 500
|
|
return "Internal Server Error", 500
|
|
|
|
return app
|