Files
xAdmin 41e08611c9 Implement optional single-image intake, Signal ingestion, and multimodal vision analysis
- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping
- Enforce strict single-image rule across Web and Signal attachment channels
- Implement token-optimized vision downscaling and JPEG compression
- Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover
- Seed and manage versioned idea-image-interpreter prompt in catalog
- Update Web UI with responsive image picker, preview chip, and Visual Context tab
- Add comprehensive automated test suite in test_image_intake.py
- Update README and Labyricorn devlog
2026-08-23 01:40:22 -07:00

250 lines
8.9 KiB
Python

"""
ThinkStorm Main Application Entrypoint
Initializes FastAPI, mounts routes, static files, Jinja2 templates, and starts the background queue worker.
"""
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .config import config, BASE_DIR
from .database import init_db, get_db
from .models import User, UserRole
from .auth import get_current_user
from .queue.worker import job_queue
from .api.ideas import router as ideas_router
from .api.admin import router as admin_router
from .api.auth_routes import router as auth_router
from .api.signal_integration import router as signal_router
from .prompts.catalog import get_all_prompts, get_all_profiles
TEMPLATES_DIR = BASE_DIR / "thinkstorm" / "templates"
STATIC_DIR = BASE_DIR / "thinkstorm" / "static"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("[ThinkStorm] Initializing operational SQLite schema and seed defaults...")
init_db()
print("[ThinkStorm] Starting background job queue worker...")
await job_queue.start()
yield
# Shutdown
print("[ThinkStorm] Shutting down...")
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
app = FastAPI(
title="ThinkStorm Orchestrator",
description="AI-Assisted Self-Hosted Idea Collection and Incubation Platform",
version=config.version,
lifespan=lifespan
)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
@app.middleware("http")
async def add_no_cache_headers(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/static/") or request.url.path.endswith(".js"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Mount API routers
app.include_router(ideas_router)
app.include_router(admin_router)
app.include_router(auth_router)
app.include_router(signal_router)
# Trash root aliases
from .api.ideas import empty_trash
app.add_api_route("/api/trash/empty", empty_trash, methods=["POST"])
app.add_api_route("/api/trash", empty_trash, methods=["DELETE"])
# Gitea OAuth callback root alias
from .api.auth_routes import gitea_oauth_callback
app.add_api_route("/auth/gitea/callback", gitea_oauth_callback, methods=["GET"])
# ----------------- Frontend HTML Routes -----------------
@app.get("/", response_class=HTMLResponse)
async def home_view(request: Request, user: User = Depends(get_current_user)):
return templates.TemplateResponse(
request=request,
name="index.html",
context={"user": user, "active_page": "home"}
)
@app.get("/ideas", response_class=HTMLResponse)
async def ideas_view(
request: Request,
state: str = None,
category: str = None,
tag: str = None,
q: str = None,
user: User = Depends(get_current_user)
):
if user.role == UserRole.ANONYMOUS:
return RedirectResponse("/login")
query = """
SELECT i.*,
GROUP_CONCAT(DISTINCT c.name) AS category_names,
GROUP_CONCAT(DISTINCT t.name) AS tag_names
FROM ideas i
LEFT JOIN idea_categories ic ON i.id = ic.idea_id
LEFT JOIN categories c ON ic.category_id = c.id
LEFT JOIN idea_tags it ON i.id = it.idea_id
LEFT JOIN tags t ON it.tag_id = t.id
WHERE 1=1
"""
params = []
if state and state.upper() != "ALL":
query += " AND i.lifecycle_state = ?"
params.append(state.upper())
elif state and state.upper() == "ALL":
query += " AND i.lifecycle_state NOT IN ('TRASHED')"
elif not state:
query += " AND i.lifecycle_state NOT IN ('QUARANTINED', 'REJECTED', 'TRASHED')"
if category:
query += " AND c.name = ?"
params.append(category)
if tag:
query += " AND t.name = ?"
params.append(tag.lstrip("#").lower())
if q:
query += " AND (i.title LIKE ? OR i.summary LIKE ? OR i.original_text LIKE ?)"
term = f"%{q}%"
params.extend([term, term, term])
query += " GROUP BY i.id ORDER BY i.submitted_at DESC LIMIT 50"
with get_db() as conn:
rows = conn.execute(query, params).fetchall()
trashed_count_row = conn.execute("SELECT COUNT(*) FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchone()
trashed_count = trashed_count_row[0] if trashed_count_row else 0
ideas = []
for r in rows:
ideas.append({
"id": r["id"],
"title": r["title"] or "Untitled Idea",
"summary": r["summary"],
"lifecycle_state": r["lifecycle_state"],
"processing_state": r["processing_state"],
"enrichment_level": r["enrichment_level"],
"claimed_by": r["claimed_by"],
"submitted_at": r["submitted_at"],
"trashed_at": r["trashed_at"] if "trashed_at" in r.keys() else None,
"categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [],
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else []
})
return templates.TemplateResponse(
request=request,
name="ideas.html",
context={
"user": user,
"ideas": ideas,
"current_state": state,
"search_query": q,
"trashed_count": trashed_count,
"active_page": "ideas"
}
)
@app.get("/ideas/{idea_id}", response_class=HTMLResponse)
async def idea_detail_view(idea_id: str, request: Request, user: User = Depends(get_current_user)):
if user.role == UserRole.ANONYMOUS:
return RedirectResponse("/login")
from .api.ideas import get_idea_detail
try:
idea_data = await get_idea_detail(idea_id, current_user=user)
except HTTPException as e:
if e.status_code == 404:
return HTMLResponse("<h1>Idea Not Found</h1>", status_code=404)
elif e.status_code == 401:
return RedirectResponse("/login")
elif e.status_code == 403:
return HTMLResponse(f"<h1>Access Denied: {e.detail}</h1>", status_code=403)
raise
return templates.TemplateResponse(
request=request,
name="idea_detail.html",
context={"user": user, "idea": idea_data, "active_page": "ideas"}
)
@app.get("/admin", response_class=HTMLResponse)
async def admin_view(request: Request, user: User = Depends(get_current_user)):
if user.role != UserRole.ADMIN:
return RedirectResponse("/login")
from .api.admin import list_services, list_jobs, list_quarantined_ideas, get_token_metrics, get_audit_logs
prompts = get_all_prompts(is_admin=True)
profiles = get_all_profiles()
services = await list_services()
jobs_data = await list_jobs()
quarantined = await list_quarantined_ideas()
metrics = await get_token_metrics()
audit_logs = await get_audit_logs()
with get_db() as conn:
trashed_rows = conn.execute("SELECT * FROM ideas WHERE lifecycle_state = 'TRASHED' ORDER BY trashed_at DESC, submitted_at DESC").fetchall()
trashed_ideas = [dict(r) for r in trashed_rows]
return templates.TemplateResponse(
request=request,
name="admin.html",
context={
"user": user,
"prompts": prompts,
"profiles": profiles,
"services": services,
"jobs_data": jobs_data,
"quarantined": quarantined,
"trashed_ideas": trashed_ideas,
"trashed_count": len(trashed_ideas),
"metrics": metrics,
"audit_logs": audit_logs,
"active_page": "admin"
}
)
@app.get("/login", response_class=HTMLResponse)
async def login_view(request: Request, user: User = Depends(get_current_user)):
if user.role != UserRole.ANONYMOUS:
return RedirectResponse("/")
return templates.TemplateResponse(request=request, name="login.html", context={"user": user, "active_page": "login"})
@app.get("/auth/gitea/callback")
async def gitea_callback_redirect(code: str, request: Request):
# Handled via auth API router
from .api.auth_routes import gitea_oauth_callback
from fastapi.responses import Response
res = Response()
await gitea_oauth_callback(code=code, request=request, response=res)
redirect = RedirectResponse("/", status_code=302)
# Forward set-cookie
for k, v in res.headers.items():
if k.lower() == "set-cookie":
redirect.headers[k] = v
return redirect
if __name__ == "__main__":
import uvicorn
uvicorn.run("thinkstorm.main:app", host=config.host, port=config.port, reload=True)