diff --git a/.coverage b/.coverage index 50b4dd7..deeb796 100644 Binary files a/.coverage and b/.coverage differ diff --git a/tensors/server/__init__.py b/tensors/server/__init__.py index 83a0f08..534ff9c 100644 --- a/tensors/server/__init__.py +++ b/tensors/server/__init__.py @@ -11,6 +11,7 @@ import httpx from fastapi import FastAPI from fastapi.responses import FileResponse +from tensors.server.civitai_routes import create_civitai_router from tensors.server.db_routes import create_db_router from tensors.server.download_routes import create_download_router from tensors.server.gallery_routes import create_gallery_router @@ -56,6 +57,7 @@ def create_app(config: ServerConfig | None = None) -> FastAPI: async def gallery_ui() -> FileResponse: return FileResponse(static_dir / "index.html") + app.include_router(create_civitai_router()) # Must be before catch-all proxy app.include_router(create_db_router()) # Must be before catch-all proxy app.include_router(create_gallery_router()) # Must be before catch-all proxy app.include_router(create_models_router(pm)) # Must be before catch-all proxy diff --git a/tensors/server/civitai_routes.py b/tensors/server/civitai_routes.py new file mode 100644 index 0000000..ab20a1b --- /dev/null +++ b/tensors/server/civitai_routes.py @@ -0,0 +1,88 @@ +"""FastAPI route handlers for CivitAI API endpoints.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from fastapi import APIRouter, Query, Response +from fastapi.responses import JSONResponse + +from tensors.config import CIVITAI_API_BASE, load_api_key + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/civitai", tags=["civitai"]) + + +def _get_headers(api_key: str | None) -> dict[str, str]: + """Get headers for CivitAI API requests.""" + headers: dict[str, str] = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +@router.get("/search", response_model=None) +async def search_models( + query: str | None = Query(default=None, description="Search query"), + types: str | None = Query(default=None, description="Model type (Checkpoint, LORA, LoCon, etc.)"), + base_models: str | None = Query(default=None, alias="baseModels", description="Base model (SD 1.5, SDXL 1.0, Pony, etc.)"), + sort: str = Query(default="Most Downloaded", description="Sort order"), + limit: int = Query(default=20, le=100, description="Max results"), + nsfw: bool = Query(default=True, description="Include NSFW models"), +) -> dict[str, Any] | Response: + """Search CivitAI models.""" + api_key = load_api_key() + + params: dict[str, Any] = { + "limit": min(limit, 100), + "nsfw": str(nsfw).lower(), + "sort": sort, + } + + if query: + params["query"] = query + if types: + params["types"] = types + if base_models: + params["baseModels"] = base_models + + url = f"{CIVITAI_API_BASE}/models" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params, headers=_get_headers(api_key)) + response.raise_for_status() + result: dict[str, Any] = response.json() + return result + except httpx.HTTPStatusError as e: + logger.error("CivitAI API error: %s", e.response.status_code) + return JSONResponse({"error": f"API error: {e.response.status_code}"}, status_code=e.response.status_code) + except httpx.RequestError as e: + logger.error("CivitAI request error: %s", e) + return JSONResponse({"error": f"Request error: {e}"}, status_code=500) + + +@router.get("/model/{model_id}", response_model=None) +async def get_model(model_id: int) -> dict[str, Any] | Response: + """Get model details from CivitAI.""" + api_key = load_api_key() + url = f"{CIVITAI_API_BASE}/models/{model_id}" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=_get_headers(api_key)) + response.raise_for_status() + result: dict[str, Any] = response.json() + return result + except httpx.HTTPStatusError: + return JSONResponse({"error": "Model not found"}, status_code=404) + except httpx.RequestError as e: + return JSONResponse({"error": f"Request error: {e}"}, status_code=500) + + +def create_civitai_router() -> APIRouter: + """Return the CivitAI API router.""" + return router diff --git a/tensors/server/static/index.html b/tensors/server/static/index.html index 0b994a2..e0db58a 100644 --- a/tensors/server/static/index.html +++ b/tensors/server/static/index.html @@ -210,6 +210,296 @@ .empty { text-align: center; padding: 4rem 1rem; color: #666; } .error { color: #dc2626; } + + /* Search view */ + #search-view { + display: flex; + flex-direction: column; + } + .search-header { + padding: 1rem; + background: #0f0f0f; + border-bottom: 1px solid #333; + } + @media (min-width: 768px) { + .search-header { padding: 1.5rem; } + } + .search-row { + display: flex; + gap: 10px; + max-width: 1000px; + margin: 0 auto; + flex-wrap: wrap; + } + .search-row input[type="text"] { + flex: 1; + min-width: 200px; + padding: 0.75rem 1rem; + border: 1px solid #333; + border-radius: 8px; + background: #1a1a1a; + color: #e5e5e5; + font-size: 1rem; + } + .search-row input[type="text"]::placeholder { color: #666; } + .search-row input[type="text"]:focus { + outline: none; + border-color: #4ade80; + } + .search-row select { + padding: 0.75rem 1rem; + border: 1px solid #333; + border-radius: 8px; + background: #1a1a1a; + color: #e5e5e5; + font-size: 0.9rem; + cursor: pointer; + } + .search-row select:focus { + outline: none; + border-color: #4ade80; + } + .search-row button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + background: #4ade80; + color: #0f0f0f; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + } + .search-row button:hover { background: #22c55e; } + .search-row button:disabled { background: #333; color: #666; cursor: not-allowed; } + + .search-results { + flex: 1; + overflow-y: auto; + padding: 1rem; + } + @media (min-width: 768px) { + .search-results { padding: 1.5rem; } + } + + /* Model cards */ + .model-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + } + @media (min-width: 768px) { + .model-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; } + } + .model-card { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + overflow: hidden; + transition: border-color 0.2s, transform 0.2s; + } + .model-card:hover { + border-color: #4ade80; + transform: translateY(-2px); + } + .model-card-image { + width: 100%; + aspect-ratio: 16/10; + object-fit: cover; + background: #0f0f0f; + } + .model-card-body { + padding: 1rem; + } + .model-card-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #e5e5e5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .model-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + .model-tag { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + .model-tag.type { + background: #4ade80; + color: #0f0f0f; + } + .model-tag.base { + background: #333; + color: #e5e5e5; + } + .model-tag.nsfw { + background: #dc2626; + color: #fff; + } + .model-card-stats { + display: flex; + gap: 1rem; + font-size: 0.8rem; + color: #888; + } + .model-card-stats span { + display: flex; + align-items: center; + gap: 0.25rem; + } + .model-card-stats svg { + width: 14px; + height: 14px; + } + .model-card-footer { + padding: 0.75rem 1rem; + border-top: 1px solid #333; + display: flex; + justify-content: space-between; + align-items: center; + } + .model-card-creator { + font-size: 0.8rem; + color: #888; + } + .model-card-actions { + display: flex; + gap: 0.5rem; + } + .model-card-actions button { + padding: 0.5rem 0.75rem; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + } + .btn-view { + background: #333; + color: #e5e5e5; + } + .btn-view:hover { background: #444; } + .btn-download { + background: #4ade80; + color: #0f0f0f; + } + .btn-download:hover { background: #22c55e; } + + /* Modal */ + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.8); + z-index: 1000; + align-items: center; + justify-content: center; + padding: 1rem; + } + .modal-overlay.active { display: flex; } + .modal { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + } + .modal-header { + padding: 1rem; + border-bottom: 1px solid #333; + display: flex; + justify-content: space-between; + align-items: center; + } + .modal-header h3 { + font-size: 1.1rem; + font-weight: 600; + } + .modal-close { + background: none; + border: none; + color: #888; + cursor: pointer; + padding: 0.25rem; + } + .modal-close:hover { color: #e5e5e5; } + .modal-body { + padding: 1rem; + } + .modal-images { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding-bottom: 0.5rem; + margin-bottom: 1rem; + } + .modal-images img { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 8px; + cursor: pointer; + border: 2px solid transparent; + } + .modal-images img:hover { border-color: #4ade80; } + .modal-info { + display: grid; + gap: 0.75rem; + } + .modal-info-row { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + } + .modal-info-row label { + color: #888; + } + .modal-info-row span { + color: #e5e5e5; + } + .modal-versions { + margin-top: 1rem; + } + .modal-versions h4 { + font-size: 0.9rem; + color: #888; + margin-bottom: 0.5rem; + } + .version-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .version-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: #0f0f0f; + border-radius: 8px; + } + .version-name { + font-size: 0.9rem; + } + .version-base { + font-size: 0.75rem; + color: #888; + }
@@ -219,6 +509,11 @@