- 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
250 lines
8.9 KiB
Python
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)
|