diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
new file mode 100644
index 00000000..547ef949
--- /dev/null
+++ b/.github/workflows/docker-build.yml
@@ -0,0 +1,54 @@
+name: Build & Push Docker Image
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=sha,prefix=,format=short
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index 4405c786..a506b12c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,6 @@ __pycache__
.idea
config.conf
generated_media
-har_and_cookies
\ No newline at end of file
+har_and_cookies
+# Playwright MCP
+.playwright-mcp/
diff --git a/Docker.md b/Docker.md
index 904f4e0f..cb2697da 100644
--- a/Docker.md
+++ b/Docker.md
@@ -1,86 +1,111 @@
-## π³ Docker Deployment Guide
+# π³ Docker Deployment Guide
-### Prerequisites
-
-Ensure you have the following installed on your system:
+## Prerequisites
- [Docker](https://docs.docker.com/get-docker/)
-- [Docker Compose v2.24+](https://docs.docker.com/compose/)
-- GNU Make (optional but recommended)
+- [Docker Compose v2](https://docs.docker.com/compose/) (included with Docker Desktop)
---
-### π οΈ Docker Environment Configuration
-
-This project uses a `.env` file for environment-specific settings like development or production mode on docker.
+## Quick Start
-#### Example `.env`
+### 1. Clone the repository
-```env
-# Set the environment mode
-ENVIRONMENT=development
+```bash
+git clone https://github.com/leolionart/WebAI-to-API.git
+cd WebAI-to-API
```
-- `ENVIRONMENT=development`: Runs the server in **development** mode with auto-reload and debug logs.
-- Change to `ENVIRONMENT=production` to enable **multi-worker production** mode with detached execution (`make up`).
+### 2. Create your config file
-> **Tip:** If this variable is not set, the default is automatically assumed to be `development`.
+```bash
+cp config.conf.example config.conf
+```
----
+Open `config.conf` and fill in your Gemini cookies:
-### π Build & Run
+```ini
+[Cookies]
+gemini_cookie_1psid = YOUR___Secure-1PSID_HERE
+gemini_cookie_1psidts = YOUR___Secure-1PSIDTS_HERE
+```
-> Use `make` commands for simplified usage.
+> **Where to get cookies:**
+> 1. Log in to [gemini.google.com](https://gemini.google.com) in your browser
+> 2. Open DevTools (`F12`) β **Application** β **Cookies** β `https://gemini.google.com`
+> 3. Copy the values of `__Secure-1PSID` and `__Secure-1PSIDTS`
-#### π§ Build the Docker image
+### 3. Start the server
```bash
-make build # Regular build
-make build-fresh # Force clean build (no cache)
+docker compose up -d
```
-#### βΆοΈ Run the server
+The API is now available at **`http://localhost:6969`**.
-```bash
-make up
-```
+---
+
+## Cookie Persistence
-Depending on the environment:
+Config is stored in a Docker named volume (`webai_data`) mapped to `/app/data/config.conf` inside the container. This means:
-- In **development**, the server runs in the foreground with hot-reloading.
-- In **production**, the server runs in **detached mode** (`-d`) with multiple workers.
+- Cookies survive container restarts and image updates
+- The server automatically rotates `__Secure-1PSIDTS` every ~10 minutes and writes the updated value back to `config.conf` β so your session stays valid without manual intervention
-#### βΉ Stop the server
+---
+
+## Useful Commands
+
+| Command | Description |
+|---------|-------------|
+| `docker compose up -d` | Start in background |
+| `docker compose down` | Stop and remove containers |
+| `docker compose logs -f` | Stream live logs |
+| `docker compose pull && docker compose up -d` | Update to latest image |
+| `docker compose restart` | Restart without recreating |
+
+Or use the provided `Makefile` shortcuts:
```bash
-make stop
+make up # docker compose up -d
+make down # docker compose down
+make logs # docker compose logs -f
+make pull # docker compose pull
+make restart # down + up
```
---
-### π§ Development Notes
+## Building Locally
-- **Reloading**: In development, the server uses `uvicorn --reload` for live updates.
-- **Logging**: On container start, it prints the current environment with colors (π‘ dev / βͺ production).
-- **Watch Mode (optional)**: Docker Compose v2.24+ supports file watching via the `compose watch` feature. If enabled, press `w` to toggle.
+If you want to build the image from source instead of pulling from GHCR:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
+```
+
+Or edit `docker-compose.yml` directly: comment out the `image:` line and uncomment `build: .`.
---
-### π¦ File Structure for Docker
+## Changing the Port
-Key files:
+Edit `docker-compose.yml` and update the port mapping:
-```plaintext
-.
-βββ Dockerfile # Base image and command logic
-βββ docker-compose.yml # Shared config (network, ports, env)
-βββ .env # Defines ENVIRONMENT (development/production)
-βββ Makefile # Simplifies Docker CLI usage
+```yaml
+ports:
+ - "8080:6969" # expose on host port 8080 instead of 6969
```
---
-### β Best Practices
+## File Overview
-- Don't use `ENVIRONMENT=development` in **production**.
-- Avoid bind mounts (`volumes`) in production to ensure image consistency.
+```
+.
+βββ Dockerfile # Image build instructions
+βββ docker-compose.yml # Main compose config (production)
+βββ docker-compose.dev.yml # Local build override
+βββ config.conf # Your config β cookies, model, proxy (gitignored)
+βββ config.conf.example # Template to copy from
+```
diff --git a/Dockerfile b/Dockerfile
index dad17df5..9b93c472 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,8 @@
FROM python:3.11-slim
+# Install build dependencies for native extensions (lz4, cffi, etc.)
+RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ && rm -rf /var/lib/apt/lists/*
+
# Install Requirements
WORKDIR /app
COPY requirements.txt .
diff --git a/Makefile b/Makefile
index 8b72b723..ed461d39 100644
--- a/Makefile
+++ b/Makefile
@@ -1,29 +1,25 @@
-# Makefile
-
-# Load .env file if it exists
-include .env
-export $(shell sed 's/=.*//' .env)
+# Makefile β convenience shortcuts for Docker operations
build:
- docker build -t cornatul/webai.ai:latest .
+ docker compose build
build-fresh:
- docker build --no-cache -t cornatul/webai.ai:latest .
+ docker compose build --no-cache
up:
- @if [ "$(ENVIRONMENT)" = "development" ]; then \
- printf "\033[1;33mπ§ͺ Running in DEVELOPMENT mode...\033[0m\n"; \
- docker-compose up; \
- else \
- printf "\033[0;37mπ Running in PRODUCTION mode...\033[0m\n"; \
- docker-compose up -d; \
- fi
+ docker compose up -d
-stop:
- docker-compose down
+up-dev:
+ docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
down:
- docker-compose down
+ docker compose down
+
+logs:
+ docker compose logs -f
+
+pull:
+ docker compose pull
-push:
- docker push cornatul/webai.ai:latest
+restart:
+ docker compose down && docker compose up -d
diff --git a/README.md b/README.md
index 7523ff17..318d8296 100644
--- a/README.md
+++ b/README.md
@@ -1,381 +1,155 @@
-## Disclaimer
-
-> **This project is intended for research and educational purposes only.**
-> Please refrain from any commercial use and act responsibly when deploying or modifying this tool.
-
----
+> **This project is intended for research and educational purposes only.**
+> Please use it responsibly and refrain from any commercial use.
# WebAI-to-API
-
-
-
-
-
-**WebAI-to-API** is a modular web server built with FastAPI that allows you to expose your preferred browser-based LLM (such as Gemini) as a local API endpoint.
-
----
-
-This project supports **two operational modes**:
-
-1. **Primary Web Server**
-
- > WebAI-to-API
-
- Connects to the Gemini web interface using your browser cookies and exposes it as an API endpoint. This method is lightweight, fast, and efficient for personal use.
-
-2. **Fallback Web Server (gpt4free)**
-
- > [gpt4free](https://github.com/xtekky/gpt4free)
-
- A secondary server powered by the `gpt4free` library, offering broader access to multiple LLMs beyond Gemini, including:
-
- - ChatGPT
- - Claude
- - DeepSeek
- - Copilot
- - HuggingFace Inference
- - Grok
- - ...and many more.
-
-This design provides both **speed and redundancy**, ensuring flexibility depending on your use case and available resources.
-
----
-
-## Features
-
-- π **Available Endpoints**:
-
- - **WebAI Server**:
-
- - `/v1/chat/completions`
- - `/gemini`
- - `/gemini-chat`
- - `/translate`
- - `/v1beta/models/{model}` (Google Generative AI v1beta API)
-
- - **gpt4free Server**:
- - `/v1`
- - `/v1/chat/completions`
-
-- π **Server Switching**: Easily switch between servers in terminal.
-
-- π οΈ **Modular Architecture**: Organized into clearly defined modules for API routes, services, configurations, and utilities, making development and maintenance straightforward.
-
-
-
-
-
----
-
-## Installation
-
-1. **Clone the repository:**
-
- ```bash
- git clone https://github.com/Amm1rr/WebAI-to-API.git
- cd WebAI-to-API
- ```
-
-2. **Install dependencies using Poetry:**
-
- ```bash
- poetry install
- ```
-
-3. **Create and update the configuration file:**
-
- ```bash
- cp config.conf.example config.conf
- ```
+A FastAPI server that exposes Google Gemini (via browser cookies) as a local OpenAI-compatible API endpoint. No API key required β it reuses your existing browser session.
- Then, edit `config.conf` to adjust service settings and other options.
+Compatible with any tool that supports the OpenAI API format: [Open WebUI](https://github.com/open-webui/open-webui), [Cursor](https://cursor.sh), [Continue](https://continue.dev), custom scripts, etc.
-4. **Run the server:**
-
- ```bash
- poetry run python src/run.py
- ```
+
---
-## Usage
-
-Send a POST request to `/v1/chat/completions` (or any other available endpoint) with the required payload.
-
-### Supported Models
-
-| Model | Description |
-|-------|-------------|
-| `gemini-3.0-pro` | Latest and most powerful model |
-| `gemini-2.5-pro` | Advanced reasoning model |
-| `gemini-2.5-flash` | Fast and efficient model (default) |
-
-### Example Request (Basic)
-
-```json
-{
- "model": "gemini-3.0-pro",
- "messages": [{ "role": "user", "content": "Hello!" }]
-}
+## Deploy with Docker Compose
+
+### 1. Create a folder and add two files
+
+**`docker-compose.yml`**
+
+```yaml
+services:
+ web_ai:
+ image: ghcr.io/leolionart/webai-to-api:latest
+ container_name: web_ai_server
+ restart: always
+ ports:
+ - "6969:6969"
+ environment:
+ - PYTHONPATH=/app/src
+ - CONFIG_PATH=/app/data/config.conf
+ volumes:
+ - ./config.conf:/app/data/config.conf
+ command: uvicorn app.main:app --host 0.0.0.0 --port 6969 --workers 1 --log-level info
```
-### Example Request (With System Prompt & Conversation History)
-
-```json
-{
- "model": "gemini-2.5-pro",
- "messages": [
- { "role": "system", "content": "You are a helpful assistant." },
- { "role": "user", "content": "What is Python?" },
- { "role": "assistant", "content": "Python is a programming language." },
- { "role": "user", "content": "Is it easy to learn?" }
- ]
-}
-```
-
-### Example Response
-
-```json
-{
- "id": "chatcmpl-12345",
- "object": "chat.completion",
- "created": 1693417200,
- "model": "gemini-3.0-pro",
- "choices": [
- {
- "message": {
- "role": "assistant",
- "content": "Hi there!"
- },
- "finish_reason": "stop",
- "index": 0
- }
- ],
- "usage": {
- "prompt_tokens": 0,
- "completion_tokens": 0,
- "total_tokens": 0
- }
-}
-```
-
----
-
-## Documentation
-
-### WebAI-to-API Endpoints
-
-> `POST /gemini`
-
-Initiates a new conversation with the LLM. Each request creates a **fresh session**, making it suitable for stateless interactions.
-
-> `POST /gemini-chat`
-
-Continues a persistent conversation with the LLM without starting a new session. Ideal for use cases that require context retention between messages.
-
-> `POST /translate`
-
-Designed for quick integration with the [Translate It!](https://github.com/iSegaro/Translate-It) browser extension.
-Functionally identical to `/gemini-chat`, meaning it **maintains session context** across requests.
-
-> `POST /v1/chat/completions`
+**`config.conf`** (leave cookies empty for now)
-**OpenAI-compatible endpoint** with full support for:
-- **System prompts**: Set behavior and context for the assistant
-- **Conversation history**: Maintain context across multiple turns (user/assistant messages)
-- **Streaming**: Optional streaming response support
-
-Built for seamless integration with clients that expect the OpenAI API format.
-
-> `POST /v1beta/models/{model}`
-
-**Google Generative AI v1beta API** compatible endpoint.
-Provides access to the latest Google Generative AI models with standard Google API format including safety ratings and structured responses.
-
----
-
-### gpt4free Endpoints
+```ini
+[Browser]
+name = chrome
-These endpoints follow the **OpenAI-compatible structure** and are powered by the `gpt4free` library.
-For detailed usage and advanced customization, refer to the official documentation:
+[AI]
+default_ai = gemini
+default_model_gemini = gemini-3.0-flash
-- π [Provider Documentation](https://github.com/gpt4free/g4f.dev/blob/main/docs/selecting_a_provider.md)
-- π [Model Documentation](https://github.com/gpt4free/g4f.dev/blob/main/docs/providers-and-models.md)
+[Cookies]
+gemini_cookie_1psid =
+gemini_cookie_1psidts =
-#### Available Endpoints (gpt4free API Layer)
+[EnabledAI]
+gemini = true
+[Proxy]
+http_proxy =
```
-GET / # Health check
-GET /v1 # Version info
-GET /v1/models # List all available models
-GET /api/{provider}/models # List models from a specific provider
-GET /v1/models/{model_name} # Get details of a specific model
-
-POST /v1/chat/completions # Chat with default configuration
-POST /api/{provider}/chat/completions
-POST /api/{provider}/{conversation_id}/chat/completions
-
-POST /v1/responses # General response endpoint
-POST /api/{provider}/responses
-
-POST /api/{provider}/images/generations
-POST /v1/images/generations
-POST /v1/images/generate # Generate images using selected provider
-
-POST /v1/media/generate # Media generation (audio/video/etc.)
-
-GET /v1/providers # List all providers
-GET /v1/providers/{provider} # Get specific provider info
-
-POST /api/{path_provider}/audio/transcriptions
-POST /v1/audio/transcriptions # Audio-to-text
-POST /api/markitdown # Markdown rendering
+### 2. Start the server
-POST /api/{path_provider}/audio/speech
-POST /v1/audio/speech # Text-to-speech
-
-POST /v1/upload_cookies # Upload session cookies (browser-based auth)
-
-GET /v1/files/{bucket_id} # Get uploaded file from bucket
-POST /v1/files/{bucket_id} # Upload file to bucket
-
-GET /v1/synthesize/{provider} # Audio synthesis
-
-POST /json/{filename} # Submit structured JSON data
-
-GET /media/{filename} # Retrieve media
-GET /images/{filename} # Retrieve images
+```bash
+docker compose up -d
```
----
+### 3. Open the admin dashboard
-## Roadmap
+Go to **`http://localhost:6969/admin`**
-- β Maintenance
+From there, paste your Gemini cookies and click **Connect** β no file editing needed.
---
-
-
-
Configuration βοΈ
-
+## Getting Gemini cookies
-### Key Configuration Options
+1. Open [gemini.google.com](https://gemini.google.com) and log in
+2. Open DevTools (`F12`) β **Network** tab β refresh the page β click any request to `gemini.google.com`
+3. Right-click the request β **Copy β Copy as cURL**
+4. Paste the cURL command into the admin dashboard β it extracts the cookies automatically
-| Section | Option | Description | Example Value |
-| ----------- | ---------- | ------------------------------------------ | ----------------------- |
-| [AI] | default_ai | Default service for `/v1/chat/completions` | `gemini` |
-| [Browser] | name | Browser for cookie-based authentication | `firefox` |
-| [EnabledAI] | gemini | Enable/disable Gemini service | `true` |
-| [Proxy] | http_proxy | Proxy for Gemini connections (optional) | `http://127.0.0.1:2334` |
+Or manually: DevTools β **Application** β **Cookies** β copy `__Secure-1PSID` and `__Secure-1PSIDTS`.
-The complete configuration template is available in [`WebAI-to-API/config.conf.example`](WebAI-to-API/config.conf.example).
-If the cookies are left empty, the application will automatically retrieve them using the default browser specified.
+Cookies are saved to `config.conf` in your folder and auto-rotated in the background β no manual refresh needed.
---
-### Sample `config.conf`
+## Using the API
-```ini
-[AI]
-# Default AI service.
-default_ai = gemini
+The server exposes an OpenAI-compatible endpoint. Point any compatible tool to:
-# Default model for Gemini (options: gemini-3.0-pro, gemini-2.5-pro, gemini-2.5-flash)
-default_model_gemini = gemini-2.5-flash
+```
+Base URL: http://localhost:6969/v1
+API Key: not-needed
+```
-# Gemini cookies (leave empty to use browser_cookies3 for automatic authentication).
-gemini_cookie_1psid =
-gemini_cookie_1psidts =
+### Supported models
-[EnabledAI]
-# Enable or disable AI services.
-gemini = true
+| Model | Description |
+| ----------------------------- | --------------------------------------- |
+| `gemini-3.0-pro` | Most capable (requires Gemini Advanced) |
+| `gemini-3.0-flash` | Fast, efficient (default) |
+| `gemini-3.0-flash-thinking` | Extended thinking |
-[Browser]
-# Default browser options: firefox, brave, chrome, edge, safari.
-name = firefox
+### Example: curl
-# --- Proxy Configuration ---
-# Optional proxy for connecting to Gemini servers.
-# Useful for fixing 403 errors or restricted connections.
-[Proxy]
-http_proxy =
+```bash
+curl http://localhost:6969/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "gemini-3.0-flash",
+ "messages": [{ "role": "user", "content": "Hello!" }]
+ }'
```
-
+### Example: OpenAI Python client
----
+```python
+from openai import OpenAI
+
+client = OpenAI(
+ base_url="http://localhost:6969/v1",
+ api_key="not-needed",
+)
-## Project Structure
-
-The project now follows a modular layout that separates configuration, business logic, API endpoints, and utilities:
-
-```plaintext
-src/
-βββ app/
-β βββ __init__.py
-β βββ main.py # FastAPI app creation, configuration, and lifespan management.
-β βββ config.py # Global configuration loader/updater.
-β βββ logger.py # Centralized logging configuration.
-β βββ endpoints/ # API endpoint routers.
-β β βββ __init__.py
-β β βββ gemini.py # Endpoints for Gemini (e.g., /gemini, /gemini-chat).
-β β βββ chat.py # Endpoints for translation and OpenAI-compatible requests.
-β β βββ google_generative.py # Google Generative AI v1beta API endpoints.
-β βββ services/ # Business logic and service wrappers.
-β β βββ __init__.py
-β β βββ gemini_client.py # Gemini client initialization, content generation, and cleanup.
-β β βββ session_manager.py # Session management for chat and translation.
-β βββ utils/ # Helper functions.
-β βββ __init__.py
-β βββ browser.py # Browser-based cookie retrieval.
-βββ models/ # Models and wrappers (e.g., MyGeminiClient).
-β βββ gemini.py
-βββ schemas/ # Pydantic schemas for request/response validation.
-β βββ request.py
-βββ config.conf # Application configuration file.
-βββ run.py # Entry point to run the server.
+response = client.chat.completions.create(
+ model="gemini-3.0-flash",
+ messages=[{"role": "user", "content": "Hello!"}],
+)
+print(response.choices[0].message.content)
```
---
-## Developer Documentation
+## Endpoints
-### Overview
-
-The project is built on a modular architecture designed for scalability and ease of maintenance. Its primary components are:
-
-- **app/main.py:** Initializes the FastAPI application, configures middleware, and manages application lifespan (startup and shutdown routines).
-- **app/config.py:** Handles the loading and updating of configuration settings from `config.conf`.
-- **app/logger.py:** Sets up a centralized logging system.
-- **app/endpoints/:** Contains separate modules for handling API endpoints. Each module (e.g., `gemini.py` and `chat.py`) manages routes specific to their functionality.
-- **app/services/:** Encapsulates business logic, including the Gemini client wrapper (`gemini_client.py`) and session management (`session_manager.py`).
-- **app/utils/browser.py:** Provides helper functions, such as retrieving cookies from the browser for authentication.
-- **models/:** Holds model definitions like `MyGeminiClient` for interfacing with the Gemini Web API.
-- **schemas/:** Defines Pydantic models for validating API requests.
-
-### How It Works
-
-1. **Application Initialization:**
- On startup, the application loads configurations and initializes the Gemini client and session managers. This is managed via the `lifespan` context in `app/main.py`.
-
-2. **Routing:**
- The API endpoints are organized into dedicated routers under `app/endpoints/`, which are then included in the main FastAPI application.
-
-3. **Service Layer:**
- The `app/services/` directory contains the logic for interacting with the Gemini API and managing user sessions, ensuring that the API routes remain clean and focused on request handling.
-
-4. **Utilities and Configurations:**
- Helper functions and configuration logic are kept separate to maintain clarity and ease of updates.
+| Method | Path | Description |
+| -------- | ------------------------ | -------------------------------------------- |
+| `GET` | `/v1/models` | List available models |
+| `POST` | `/v1/chat/completions` | OpenAI-compatible chat (streaming supported) |
+| `POST` | `/gemini` | Stateless single-turn request |
+| `POST` | `/gemini-chat` | Stateful multi-turn chat |
+| `POST` | `/translate` | Translation (alias for `/gemini-chat`) |
+| `GET` | `/admin` | Admin dashboard |
+| `GET` | `/docs` | Swagger UI |
---
-## π³ Docker Deployment Guide
+## Common commands
-For Docker setup and deployment instructions, please refer to the [Docker.md](Docker.md) documentation.
+```bash
+docker compose up -d # start
+docker compose down # stop
+docker compose logs -f # live logs
+docker compose pull && docker compose up -d # update to latest
+```
---
@@ -383,14 +157,6 @@ For Docker setup and deployment instructions, please refer to the [Docker.md](Do
[](https://www.star-history.com/#Amm1rr/WebAI-to-API&Date)
-## License π
-
-This project is open source under the [MIT License](LICENSE).
-
----
-
-> **Note:** This is a research project. Please use it responsibly, and be aware that additional security measures and error handling are necessary for production deployments.
-
-
+## License
-[](https://github.com/Amm1rr/)
+[MIT License](LICENSE)
diff --git a/assets/1771806187019.png b/assets/1771806187019.png
new file mode 100644
index 00000000..ad48ba7f
Binary files /dev/null and b/assets/1771806187019.png differ
diff --git a/assets/Server-Run-G4F.png b/assets/Server-Run-G4F.png
deleted file mode 100644
index 4505d4ba..00000000
Binary files a/assets/Server-Run-G4F.png and /dev/null differ
diff --git a/assets/Server-Run-WebAI.png b/assets/Server-Run-WebAI.png
deleted file mode 100644
index e8eb55fc..00000000
Binary files a/assets/Server-Run-WebAI.png and /dev/null differ
diff --git a/assets/page-2026-02-22T12-20-36-999Z.png b/assets/page-2026-02-22T12-20-36-999Z.png
new file mode 100644
index 00000000..7744891a
Binary files /dev/null and b/assets/page-2026-02-22T12-20-36-999Z.png differ
diff --git a/config.conf.example b/config.conf.example
index e963a2a9..534ea416 100755
--- a/config.conf.example
+++ b/config.conf.example
@@ -19,14 +19,10 @@ default_ai = gemini
# --- Gemini Model Configuration ---
# Choose the model to be used when Gemini is selected as the AI.
# Available models:
-# - gemini-1.5-flash
-# - gemini-2.0-flash
-# - gemini-2.0-flash-thinking
-# - gemini-2.0-flash-thinking-with-apps
-# - gemini-2.5-pro
-# - gemini-2.5-flash
-# - gemini-3.0-pro
-default_model_gemini = gemini-3.0-pro
+# - gemini-3.0-pro (most capable, requires Gemini Advanced)
+# - gemini-3.0-flash (fast, default)
+# - gemini-3.0-flash-thinking (with extended thinking)
+default_model_gemini = gemini-3.0-flash
# --- Gemini Cookies ---
# Provide your authentication cookies for Gemini here.
@@ -46,4 +42,18 @@ gemini = true
# Its usefull to fix errors like 403 or restricted connections
# Example: http_proxy = http://127.0.0.1:2334
[Proxy]
-http_proxy =
\ No newline at end of file
+http_proxy =
+
+# --- Telegram Notifications ---
+# Send alerts to a Telegram chat when API errors occur (auth failures, 5xx errors).
+# How to set up:
+# 1. Message @BotFather on Telegram β /newbot β copy the token
+# 2. Add the bot to your group/channel, or use a personal chat
+# 3. Get your chat_id: message the bot, then visit
+# https://api.telegram.org/bot/getUpdates
+# cooldown_seconds: minimum gap (seconds) between alerts of the same type (default 60)
+[Telegram]
+enabled = false
+bot_token =
+chat_id =
+cooldown_seconds = 60
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 00000000..5b0647a7
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,9 @@
+# docker-compose.dev.yml
+
+# Development override - builds image locally instead of pulling from GHCR.
+# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
+
+services:
+ web_ai:
+ build: .
+ image: webai-to-api:dev
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
deleted file mode 100644
index 82fbdc6e..00000000
--- a/docker-compose.override.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# docker-compose.override.yml
-
-# This file is for development mode only.
-
-services:
- web_ai:
- develop:
- watch:
- - action: sync
- path: ./src
- target: /app/src
- - action: sync
- path: ./app
- target: /app/app
- - action: sync
- path: ./requirements.txt
- target: /app/requirements.txt
diff --git a/docker-compose.yml b/docker-compose.yml
index 8ff27e4a..fc201531 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,21 +1,13 @@
-# docker-compose.yml
-
-# This file is for production mode only.
-
services:
web_ai:
- build: .
- image: cornatul/webai.ai
+ image: ghcr.io/leolionart/webai-to-api:latest
container_name: web_ai_server
restart: always
ports:
- "6969:6969"
- env_file:
- - .env
environment:
- PYTHONPATH=/app/src
- - ENVIRONMENT=${ENVIRONMENT:-production}
- command: >
- sh -c "
- uvicorn app.main:app --host 0.0.0.0 --port 6969 --workers 4 --log-level info;
- "
+ - CONFIG_PATH=/app/data/config.conf
+ volumes:
+ - ./data:/app/data
+ command: uvicorn app.main:app --host 0.0.0.0 --port 6969 --workers 1 --log-level info
diff --git a/pyproject.toml b/pyproject.toml
index a5384093..a968c098 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,7 +2,7 @@
[tool.poetry]
name = "webai-to-api"
-version = "0.4.0"
+version = "0.7.0"
description = "WebAI-to-API is a modular web server built with FastAPI, designed to manage requests across AI services."
authors = ["Mohammad "]
license = "MIT"
@@ -22,6 +22,8 @@ httpx = ">=0.28.1,<0.29.0"
curl-cffi = ">=0.7.4,<0.15.0"
gemini-webapi = ">=1.8.3,<2.0.0"
uvicorn = {extras = ["standard"], version = ">=0.34.0,<0.41.0"}
+sse-starlette = ">=2.1.0,<4.0.0"
+jinja2 = ">=3.1.0,<4.0.0"
# Windows-specific dependencies for cookie decryption
# These will only be installed on Windows systems
diff --git a/requirements.txt b/requirements.txt
index 5939d565..e753636b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,33 +1,12 @@
-annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
-anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0"
-browser-cookie3==0.20.1 ; python_version >= "3.10" and python_version < "4.0"
-certifi==2024.12.14 ; python_version >= "3.10" and python_version < "4.0"
-cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0"
-click==8.1.8 ; python_version >= "3.10" and python_version < "4.0"
-colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" or python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
-curl-cffi==0.7.4 ; python_version >= "3.10" and python_version < "4.0"
-exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "3.11"
-fastapi==0.115.7 ; python_version >= "3.10" and python_version < "4.0"
-gemini-webapi==1.8.3 ; python_version >= "3.10" and python_version < "4.0"
-h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
-h2==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
-hpack==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
-httpcore==1.0.7 ; python_version >= "3.10" and python_version < "4.0"
-httpx==0.28.1 ; python_version >= "3.10" and python_version < "4.0"
-hyperframe==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
-idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
-jeepney==0.8.0 ; python_version >= "3.10" and python_version < "4.0" and "bsd" in sys_platform or python_version >= "3.10" and python_version < "4.0" and sys_platform == "linux"
-loguru==0.7.3 ; python_version >= "3.10" and python_version < "4.0"
-lz4==4.4.3 ; python_version >= "3.10" and python_version < "4.0"
-pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0"
-pycryptodomex==3.21.0 ; python_version >= "3.10" and python_version < "4.0"
-pydantic-core==2.27.2 ; python_version >= "3.10" and python_version < "4.0"
-pydantic==2.10.6 ; python_version >= "3.10" and python_version < "4.0"
-pywin32==308 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
-shadowcopy==0.0.4 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
-sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
-starlette==0.45.3 ; python_version >= "3.10" and python_version < "4.0"
-typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
-uvicorn==0.34.0 ; python_version >= "3.10" and python_version < "4.0"
-win32-setctime==1.2.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
-wmi==1.5.1 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
+fastapi>=0.115.7
+uvicorn[standard]>=0.34.0
+gemini-webapi==1.19.2
+browser-cookie3>=0.20.1
+httpx[http2]>=0.28.1,<0.29.0
+curl-cffi>=0.7.4
+sse-starlette>=2.1.0
+jinja2>=3.1.0
+pydantic>=2.12.5
+orjson>=3.11.7
+loguru>=0.7.3
+python-multipart>=0.0.9
diff --git a/src/app/config.py b/src/app/config.py
index b5b29841..e4b72c0c 100644
--- a/src/app/config.py
+++ b/src/app/config.py
@@ -1,11 +1,58 @@
# src/app/config.py
import configparser
import logging
+import os
+import shutil
logger = logging.getLogger(__name__)
+# Allow overriding config path via environment variable.
+# In Docker, set CONFIG_PATH=/app/data/config.conf with a volume on /app/data.
+DEFAULT_CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.conf")
-def load_config(config_file: str = "config.conf") -> configparser.ConfigParser:
+
+def _ensure_config_exists(config_file: str) -> None:
+ """If config_file doesn't exist, copy from bundled default or create empty.
+
+ Handles the Docker volume edge-case where Docker creates a *directory* at the
+ config path when the host file doesn't exist yet. We remove the empty directory
+ and replace it with the proper file so no manual intervention is required.
+ """
+ if os.path.isdir(config_file):
+ # Docker created a directory here instead of a file β remove it and continue.
+ try:
+ shutil.rmtree(config_file)
+ logger.info(
+ f"Removed directory at '{config_file}' (created by Docker volume mount); "
+ "replacing with config file."
+ )
+ except Exception as e:
+ logger.error(f"Could not remove directory '{config_file}': {e}")
+ return
+
+ if os.path.exists(config_file):
+ return
+
+ # Create parent directory if needed
+ parent = os.path.dirname(config_file)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+
+ # Copy bundled template as starting point
+ bundled = os.path.join(os.path.dirname(os.path.dirname(__file__)), "..", "config.conf")
+ if os.path.isfile(bundled):
+ shutil.copy2(bundled, config_file)
+ logger.info(f"Copied bundled config to '{config_file}'")
+ else:
+ # Fallback: create an empty file so configparser has something to read
+ open(config_file, "w", encoding="utf-8").close()
+ logger.info(f"Created empty config file at '{config_file}'")
+
+
+def load_config(config_file: str = None) -> configparser.ConfigParser:
+ if config_file is None:
+ config_file = DEFAULT_CONFIG_PATH
+ _ensure_config_exists(config_file)
config = configparser.ConfigParser()
try:
# FIX: Explicitly specify UTF-8 encoding to prevent UnicodeDecodeError on Windows.
@@ -24,9 +71,16 @@ def load_config(config_file: str = "config.conf") -> configparser.ConfigParser:
if "Cookies" not in config:
config["Cookies"] = {}
if "AI" not in config:
- config["AI"] = {"default_model_gemini": "gemini-3.0-pro"}
+ config["AI"] = {"default_model_gemini": "gemini-3.0-flash"}
if "Proxy" not in config:
config["Proxy"] = {"http_proxy": ""}
+ if "Telegram" not in config:
+ config["Telegram"] = {
+ "enabled": "false",
+ "bot_token": "",
+ "chat_id": "",
+ "cooldown_seconds": "60",
+ }
# Save changes to the configuration file, also with UTF-8 encoding.
try:
@@ -39,5 +93,18 @@ def load_config(config_file: str = "config.conf") -> configparser.ConfigParser:
return config
+def write_config(config: configparser.ConfigParser, config_file: str = None) -> bool:
+ """Write the current config state to disk."""
+ if config_file is None:
+ config_file = DEFAULT_CONFIG_PATH
+ try:
+ with open(config_file, "w", encoding="utf-8") as f:
+ config.write(f)
+ return True
+ except Exception as e:
+ logger.error(f"Error writing to config file: {e}")
+ return False
+
+
# Load configuration globally
CONFIG = load_config()
diff --git a/src/app/endpoints/admin.py b/src/app/endpoints/admin.py
new file mode 100644
index 00000000..632c6b3f
--- /dev/null
+++ b/src/app/endpoints/admin.py
@@ -0,0 +1,17 @@
+# src/app/endpoints/admin.py
+from pathlib import Path
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+router = APIRouter(tags=["Admin"])
+
+TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates"
+templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
+
+
+@router.get("/admin", response_class=HTMLResponse)
+async def admin_page(request: Request):
+ """Serve the admin UI single-page application."""
+ return templates.TemplateResponse("admin.html", {"request": request})
diff --git a/src/app/endpoints/admin_api.py b/src/app/endpoints/admin_api.py
new file mode 100644
index 00000000..43d46e23
--- /dev/null
+++ b/src/app/endpoints/admin_api.py
@@ -0,0 +1,285 @@
+# src/app/endpoints/admin_api.py
+import json
+import tomllib
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+from sse_starlette.sse import EventSourceResponse
+
+from app.config import CONFIG, write_config
+from app.logger import logger
+from app.services.gemini_client import (
+ GeminiClientNotInitializedError,
+ get_client_status,
+ get_gemini_client,
+ init_gemini_client,
+)
+from app.services.curl_parser import parse_curl_command
+from app.services.log_broadcaster import SSELogBroadcaster
+from app.services.stats_collector import StatsCollector
+from app.services.telegram_notifier import TelegramNotifier
+
+router = APIRouter(prefix="/api/admin", tags=["Admin API"])
+
+# Read version once at import time
+def _read_version() -> str:
+ try:
+ pyproject = Path(__file__).resolve().parents[3] / "pyproject.toml"
+ with open(pyproject, "rb") as f:
+ return tomllib.load(f)["tool"]["poetry"]["version"]
+ except Exception:
+ return "unknown"
+
+_VERSION = _read_version()
+
+
+# --- Request models ---
+
+
+class CurlImportRequest(BaseModel):
+ curl_text: str
+
+
+class CookieUpdateRequest(BaseModel):
+ secure_1psid: str
+ secure_1psidts: str
+
+
+class ModelUpdateRequest(BaseModel):
+ model: str
+
+
+class ProxyUpdateRequest(BaseModel):
+ http_proxy: str
+
+
+class TelegramUpdateRequest(BaseModel):
+ enabled: bool
+ bot_token: str
+ chat_id: str
+ cooldown_seconds: int = 60
+ notify_types: list[str] = ["auth"]
+
+
+# --- Dashboard ---
+
+
+@router.get("/status")
+async def get_status():
+ """Return overall system status for the dashboard."""
+ stats = StatsCollector.get_instance().get_stats()
+ client_status = get_client_status()
+
+ try:
+ get_gemini_client()
+ gemini_status = "connected"
+ except GeminiClientNotInitializedError:
+ gemini_status = "disconnected"
+
+ return {
+ "version": _VERSION,
+ "gemini_status": gemini_status,
+ "client_error": client_status.get("error"),
+ "error_code": client_status.get("error_code"),
+ "current_model": CONFIG["AI"].get("default_model_gemini", "unknown"),
+ "proxy": CONFIG["Proxy"].get("http_proxy", ""),
+ "browser": CONFIG["Browser"].get("name", "unknown"),
+ "stats": stats,
+ }
+
+
+# --- Config ---
+
+
+@router.get("/config")
+async def get_config():
+ """Return current configuration (masking sensitive cookie values)."""
+ return {
+ "browser": CONFIG["Browser"].get("name", "chrome"),
+ "model": CONFIG["AI"].get("default_model_gemini", "gemini-3.0-pro"),
+ "proxy": CONFIG["Proxy"].get("http_proxy", ""),
+ "cookies_set": bool(
+ CONFIG["Cookies"].get("gemini_cookie_1psid")
+ and CONFIG["Cookies"].get("gemini_cookie_1psidts")
+ ),
+ "cookie_1psid_preview": _mask_value(
+ CONFIG["Cookies"].get("gemini_cookie_1psid", "")
+ ),
+ "cookie_1psidts_preview": _mask_value(
+ CONFIG["Cookies"].get("gemini_cookie_1psidts", "")
+ ),
+ "gemini_enabled": CONFIG.getboolean("EnabledAI", "gemini", fallback=True),
+ "available_models": [
+ "gemini-3.0-pro",
+ "gemini-3.0-flash",
+ "gemini-3.0-flash-thinking",
+ ],
+ }
+
+
+@router.post("/config/curl-import")
+async def import_from_curl(request: CurlImportRequest):
+ """Parse a cURL command or cookie string and extract Gemini cookies."""
+ result = parse_curl_command(request.curl_text)
+ if not result.is_valid:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "message": "Could not extract required cookies",
+ "errors": result.errors,
+ "found_cookies": list(result.all_cookies.keys()),
+ },
+ )
+ CONFIG["Cookies"]["gemini_cookie_1psid"] = result.secure_1psid
+ CONFIG["Cookies"]["gemini_cookie_1psidts"] = result.secure_1psidts
+ write_config(CONFIG)
+ logger.info("Cookies imported from cURL, reinitializing client...")
+ success = await init_gemini_client()
+ status = get_client_status()
+ return {
+ "success": success,
+ "cookies_saved": True,
+ "message": (
+ "Cookies imported and client connected successfully!"
+ if success
+ else "Cookies saved but connection failed"
+ ),
+ "error_code": status.get("error_code"),
+ "error_detail": status.get("error"),
+ "url_detected": result.url,
+ }
+
+
+@router.post("/config/cookies")
+async def update_cookies(request: CookieUpdateRequest):
+ """Update cookie values and reinitialize the Gemini client."""
+ CONFIG["Cookies"]["gemini_cookie_1psid"] = request.secure_1psid
+ CONFIG["Cookies"]["gemini_cookie_1psidts"] = request.secure_1psidts
+ write_config(CONFIG)
+ logger.info("Cookies updated via admin UI, reinitializing client...")
+ success = await init_gemini_client()
+ status = get_client_status()
+ return {
+ "success": success,
+ "cookies_saved": True,
+ "message": "Client connected successfully!" if success else "Cookies saved but connection failed",
+ "error_code": status.get("error_code"),
+ "error_detail": status.get("error"),
+ }
+
+
+@router.post("/config/model")
+async def update_model(request: ModelUpdateRequest):
+ """Update the default Gemini model."""
+ CONFIG["AI"]["default_model_gemini"] = request.model
+ write_config(CONFIG)
+ return {"success": True, "model": request.model}
+
+
+@router.post("/config/proxy")
+async def update_proxy(request: ProxyUpdateRequest):
+ """Update proxy settings and reinitialize client."""
+ CONFIG["Proxy"]["http_proxy"] = request.http_proxy
+ write_config(CONFIG)
+ logger.info("Proxy updated, reinitializing client...")
+ success = await init_gemini_client()
+ return {"success": success}
+
+
+@router.post("/client/reinitialize")
+async def reinitialize_client():
+ """Force reinitialize the Gemini client with current config."""
+ success = await init_gemini_client()
+ status = get_client_status()
+ return {
+ "success": success,
+ "message": "Client connected successfully!" if success else "Connection failed",
+ "error_code": status.get("error_code"),
+ "error_detail": status.get("error"),
+ }
+
+
+# --- SSE Logs ---
+
+
+@router.get("/logs/stream")
+async def stream_logs(request: Request, last_id: int = 0):
+ """SSE endpoint for real-time log streaming."""
+ broadcaster = SSELogBroadcaster.get_instance()
+
+ async def event_generator():
+ async for entry in broadcaster.subscribe(last_id):
+ if await request.is_disconnected():
+ break
+ yield {
+ "event": "log",
+ "id": str(entry["id"]),
+ "data": json.dumps(entry),
+ }
+
+ return EventSourceResponse(event_generator())
+
+
+@router.get("/logs/recent")
+async def get_recent_logs(count: int = 50):
+ """Return recent log entries for initial page load."""
+ broadcaster = SSELogBroadcaster.get_instance()
+ return {"logs": broadcaster.get_recent(count)}
+
+
+# --- Telegram ---
+
+
+@router.get("/config/telegram")
+async def get_telegram_config():
+ """Return current Telegram notification settings (token masked)."""
+ section = CONFIG["Telegram"] if "Telegram" in CONFIG else {}
+ bot_token = section.get("bot_token", "")
+ raw_types = section.get("notify_types", "auth").strip()
+ notify_types = [t.strip() for t in raw_types.split(",") if t.strip()]
+ return {
+ "enabled": str(section.get("enabled", "false")).lower() == "true",
+ "bot_token_preview": _mask_value(bot_token),
+ "chat_id": section.get("chat_id", ""),
+ "cooldown_seconds": int(section.get("cooldown_seconds", 60)),
+ "notify_types": notify_types,
+ }
+
+
+@router.post("/config/telegram")
+async def update_telegram_config(request: TelegramUpdateRequest):
+ """Save Telegram notification settings."""
+ if "Telegram" not in CONFIG:
+ CONFIG["Telegram"] = {}
+ CONFIG["Telegram"]["enabled"] = "true" if request.enabled else "false"
+ CONFIG["Telegram"]["bot_token"] = request.bot_token
+ CONFIG["Telegram"]["chat_id"] = request.chat_id
+ CONFIG["Telegram"]["cooldown_seconds"] = str(request.cooldown_seconds)
+ CONFIG["Telegram"]["notify_types"] = ",".join(request.notify_types)
+ write_config(CONFIG)
+ logger.info(f"Telegram notifications {'enabled' if request.enabled else 'disabled'} (types: {request.notify_types}).")
+ return {"success": True}
+
+
+@router.post("/config/telegram/test")
+async def test_telegram_notification():
+ """Send a test Telegram message using the currently saved credentials."""
+ section = CONFIG["Telegram"] if "Telegram" in CONFIG else {}
+ bot_token = section.get("bot_token", "").strip()
+ chat_id = section.get("chat_id", "").strip()
+ if not bot_token or not chat_id:
+ raise HTTPException(status_code=400, detail="bot_token and chat_id must be configured first.")
+ notifier = TelegramNotifier.get_instance()
+ ok, msg = await notifier.send_test(bot_token, chat_id)
+ return {"success": ok, "message": msg}
+
+
+# --- Helpers ---
+
+
+def _mask_value(value: str) -> str:
+ """Show first 8 and last 4 chars, mask the rest."""
+ if not value or len(value) < 16:
+ return "***" if value else ""
+ return f"{value[:8]}...{value[-4:]}"
diff --git a/src/app/endpoints/chat.py b/src/app/endpoints/chat.py
index 4c496509..b0586929 100644
--- a/src/app/endpoints/chat.py
+++ b/src/app/endpoints/chat.py
@@ -1,13 +1,211 @@
# src/app/endpoints/chat.py
+import json
import time
+from pathlib import Path
+from typing import List, Optional, Tuple
+
from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+
+from app.config import CONFIG
from app.logger import logger
-from schemas.request import GeminiRequest, OpenAIChatRequest
-from app.services.gemini_client import get_gemini_client, GeminiClientNotInitializedError
+from app.services.gemini_client import GeminiClientNotInitializedError, get_gemini_client
+from app.services.telegram_notifier import TelegramNotifier
from app.services.session_manager import get_translate_session_manager
+from app.utils.image_utils import (
+ cleanup_temp_files,
+ decode_base64_to_tempfile,
+ download_to_tempfile,
+ get_temp_dir,
+ serialize_response_images,
+)
+from schemas.request import GeminiModels, GeminiRequest, OpenAIChatRequest
router = APIRouter()
+
+# ---------------------------------------------------------------------------
+# Model resolution β map any string to a valid GeminiModels value
+# ---------------------------------------------------------------------------
+
+# Explicit aliases: covers Home Assistant / OpenAI-style names and legacy names
+_MODEL_ALIASES: dict[str, GeminiModels] = {
+ # gemini-webapi canonical names (pass-through)
+ "gemini-3.0-pro": GeminiModels.PRO,
+ "gemini-3.0-flash": GeminiModels.FLASH,
+ "gemini-3.0-flash-thinking": GeminiModels.FLASH_THINKING,
+ # Home Assistant / common variants
+ "gemini-pro": GeminiModels.PRO,
+ "gemini-ultra": GeminiModels.PRO,
+ "gemini-flash": GeminiModels.FLASH,
+ "gemini-1.0-pro": GeminiModels.PRO,
+ "gemini-1.5-pro": GeminiModels.PRO,
+ "gemini-1.5-pro-latest": GeminiModels.PRO,
+ "gemini-1.5-flash": GeminiModels.FLASH,
+ "gemini-1.5-flash-latest": GeminiModels.FLASH,
+ "gemini-2.0-flash": GeminiModels.FLASH,
+ "gemini-2.0-flash-exp": GeminiModels.FLASH,
+ "gemini-2.0-pro": GeminiModels.PRO,
+ "gemini-2.5-pro": GeminiModels.PRO,
+ "gemini-2.5-flash": GeminiModels.FLASH,
+ "gemini-3-pro": GeminiModels.PRO,
+ "gemini-3-flash": GeminiModels.FLASH,
+ "gemini-3-flash-thinking": GeminiModels.FLASH_THINKING,
+}
+
+
+def _resolve_model(model_str: Optional[str]) -> GeminiModels:
+ """
+ Resolve any model string to a supported GeminiModels value.
+
+ Lookup priority:
+ 1. Exact match in alias table (case-insensitive)
+ 2. Substring heuristics: "thinking" β FLASH_THINKING, "pro" β PRO, "flash" β FLASH
+ 3. Default: FLASH
+
+ Logs a warning when an unknown name is mapped so the operator can see what HA is sending.
+ """
+ if not model_str:
+ return GeminiModels.FLASH
+
+ lower = model_str.strip().lower()
+
+ # Exact alias match
+ if lower in _MODEL_ALIASES:
+ return _MODEL_ALIASES[lower]
+
+ # Substring heuristics (handles "gemini-3-pro-image-preview" etc.)
+ if "thinking" in lower:
+ resolved = GeminiModels.FLASH_THINKING
+ elif "pro" in lower:
+ resolved = GeminiModels.PRO
+ elif "flash" in lower:
+ resolved = GeminiModels.FLASH
+ else:
+ resolved = GeminiModels.FLASH
+
+ logger.warning(
+ f"Unknown model '{model_str}' β mapped to '{resolved.value}'. "
+ f"Add an explicit alias in _MODEL_ALIASES if needed."
+ )
+ return resolved
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _get_cookies(gemini_client) -> dict:
+ """Extract session cookies from the underlying Gemini web client."""
+ try:
+ return dict(gemini_client.client.cookies)
+ except Exception:
+ return {}
+
+
+async def _extract_multimodal_content(content) -> Tuple[str, List[Path]]:
+ """
+ Parse a message ``content`` field that may be:
+ - a plain string
+ - a list of content part dicts
+
+ Supports both Chat Completions and Responses API part types:
+ - Chat Completions: ``{"type": "text"|"image_url", ...}``
+ - Responses API: ``{"type": "input_text"|"input_image", ...}``
+
+ For ``image_url``/``input_image``, the URL value may be:
+ - Chat Completions: ``{"image_url": {"url": "data:..."}}``)
+ - Responses API: ``{"image_url": "data:..."}`` (direct string)
+
+ Returns ``(text_prompt, temp_file_paths)``.
+ Temp files are created for base64 data URIs and remote URL images; the caller
+ is responsible for cleaning them up after use.
+ """
+ if isinstance(content, str):
+ return content, []
+
+ if not isinstance(content, list):
+ return str(content) if content else "", []
+
+ text_parts: List[str] = []
+ file_paths: List[Path] = []
+
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+
+ part_type = part.get("type", "")
+
+ # Text parts β Chat Completions ("text") and Responses API ("input_text")
+ if part_type in ("text", "input_text"):
+ txt = part.get("text", "")
+ if txt:
+ text_parts.append(txt)
+
+ # Image parts β Chat Completions ("image_url") and Responses API ("input_image")
+ elif part_type in ("image_url", "input_image"):
+ img_url_obj = part.get("image_url", {})
+ # Chat Completions: image_url is {"url": "...", "detail": "..."}
+ # Responses API: image_url is a direct string "data:..." or "https://..."
+ url: str = img_url_obj.get("url", "") if isinstance(img_url_obj, dict) else str(img_url_obj)
+
+ if not url:
+ continue
+
+ if url.startswith("data:"):
+ # base64 data URI
+ try:
+ temp_path = decode_base64_to_tempfile(url)
+ file_paths.append(temp_path)
+ except ValueError as exc:
+ logger.warning(f"Skipping invalid base64 image: {exc}")
+
+ elif url.startswith("file://"):
+ # Reference to a previously uploaded file β resolve file_id
+ file_id = url[len("file://"):]
+ # Sanitize
+ if "/" not in file_id and "\\" not in file_id and ".." not in file_id:
+ candidate = get_temp_dir() / file_id
+ if candidate.exists():
+ file_paths.append(candidate)
+ else:
+ logger.warning(f"File not found for file_id: {file_id}")
+ else:
+ logger.warning(f"Invalid file_id in URL: {url}")
+
+ elif url.startswith("http://") or url.startswith("https://"):
+ # Remote image URL β download to temp file
+ temp_path = await download_to_tempfile(url)
+ if temp_path:
+ file_paths.append(temp_path)
+
+ return " ".join(text_parts), file_paths
+
+
+# ---------------------------------------------------------------------------
+# Model listing
+# ---------------------------------------------------------------------------
+
+@router.get("/v1/models")
+async def list_models():
+ """List available models in OpenAI-compatible format."""
+ now = int(time.time())
+ models = [
+ {
+ "id": m.value,
+ "object": "model",
+ "created": now,
+ "owned_by": "google",
+ }
+ for m in GeminiModels
+ ]
+ return {"object": "list", "data": models}
+
+
+# ---------------------------------------------------------------------------
+# Translation endpoint
+# ---------------------------------------------------------------------------
+
@router.post("/translate")
async def translate_chat(request: GeminiRequest):
try:
@@ -19,15 +217,28 @@ async def translate_chat(request: GeminiRequest):
if not session_manager:
raise HTTPException(status_code=503, detail="Session manager is not initialized.")
try:
- # This call now correctly uses the fixed session manager
response = await session_manager.get_response(request.model, request.message, request.files)
return {"response": response.text}
except Exception as e:
logger.error(f"Error in /translate endpoint: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error during translation: {str(e)}")
-def convert_to_openai_format(response_text: str, model: str, stream: bool = False):
- return {
+
+# ---------------------------------------------------------------------------
+# OpenAI-compatible streaming helpers
+# ---------------------------------------------------------------------------
+
+def _to_openai_format(response_text: str, model: str, images: list, stream: bool = False) -> dict:
+ """Build an OpenAI-compatible chat completion response dict."""
+ content = response_text
+ # Append image references as markdown if present (keeps text content useful)
+ if images:
+ md_links = "\n".join(
+ f"![{img['title']}]({img['url']})" for img in images
+ )
+ content = f"{response_text}\n\n{md_links}".strip()
+
+ result = {
"id": f"chatcmpl-{int(time.time())}",
"object": "chat.completion.chunk" if stream else "chat.completion",
"created": int(time.time()),
@@ -37,7 +248,7 @@ def convert_to_openai_format(response_text: str, model: str, stream: bool = Fals
"index": 0,
"message": {
"role": "assistant",
- "content": response_text,
+ "content": content,
},
"finish_reason": "stop",
}
@@ -48,47 +259,154 @@ def convert_to_openai_format(response_text: str, model: str, stream: bool = Fals
"total_tokens": 0,
},
}
+ # Attach raw images as a top-level extension field
+ if images:
+ result["images"] = images
+ return result
+
+
+async def _stream_response(response_text: str, model: str, images: list):
+ """Yield SSE chunks in OpenAI streaming format."""
+ completion_id = f"chatcmpl-{int(time.time())}"
+ created = int(time.time())
+
+ content = response_text
+ if images:
+ md_links = "\n".join(f"![{img['title']}]({img['url']})" for img in images)
+ content = f"{response_text}\n\n{md_links}".strip()
+
+ first_chunk = {
+ "id": completion_id,
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": model,
+ "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
+ }
+ yield f"data: {json.dumps(first_chunk)}\n\n"
+
+ content_chunk = {
+ "id": completion_id,
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": model,
+ "choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}],
+ }
+ if images:
+ content_chunk["images"] = images
+ yield f"data: {json.dumps(content_chunk)}\n\n"
+
+ final_chunk = {
+ "id": completion_id,
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": model,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
+ }
+ yield f"data: {json.dumps(final_chunk)}\n\n"
+ yield "data: [DONE]\n\n"
+
+
+# ---------------------------------------------------------------------------
+# OpenAI-compatible chat completions
+# ---------------------------------------------------------------------------
@router.post("/v1/chat/completions")
async def chat_completions(request: OpenAIChatRequest):
+ """
+ OpenAI-compatible chat completion endpoint with multimodal support.
+
+ Supports:
+ - Plain text messages
+ - ``image_url`` content parts with base64 data URIs (``data:image/...;base64,...``)
+ - ``image_url`` content parts with remote HTTPS URLs (downloaded automatically)
+ - ``image_url`` content parts with ``file://`` references to uploaded file IDs
+ - ``thoughts`` field in response (thinking models)
+ - ``images`` field in response (web/generated images)
+ """
try:
gemini_client = get_gemini_client()
except GeminiClientNotInitializedError as e:
raise HTTPException(status_code=503, detail=str(e))
- is_stream = request.stream if request.stream is not None else False
+ is_stream = bool(request.stream)
if not request.messages:
raise HTTPException(status_code=400, detail="No messages provided.")
- # Build conversation prompt with system prompt and full history
- conversation_parts = []
+ # Resolve model string β GeminiModels (handles HA aliases like "gemini-3-pro-image-preview")
+ gemini_model = _resolve_model(request.model)
+ model_value = gemini_model.value
+
+ # Parse all messages β collect text parts and any image file paths
+ conversation_parts: List[str] = []
+ all_file_paths: List[Path] = []
+ # Track which paths are temp files that should be cleaned up
+ temp_file_paths: List[Path] = []
for msg in request.messages:
role = msg.get("role", "user")
- content = msg.get("content", "")
- if not content:
+ raw_content = msg.get("content", "")
+
+ text, file_paths = await _extract_multimodal_content(raw_content)
+
+ # Mark newly created temp files for cleanup
+ for fp in file_paths:
+ if str(fp).startswith(str(get_temp_dir())):
+ temp_file_paths.append(fp)
+ all_file_paths.extend(file_paths)
+
+ if not text:
continue
if role == "system":
- conversation_parts.append(f"System: {content}")
+ conversation_parts.append(f"System: {text}")
elif role == "user":
- conversation_parts.append(f"User: {content}")
+ conversation_parts.append(f"User: {text}")
elif role == "assistant":
- conversation_parts.append(f"Assistant: {content}")
+ conversation_parts.append(f"Assistant: {text}")
if not conversation_parts:
raise HTTPException(status_code=400, detail="No valid messages found.")
- # Join all parts with newlines
final_prompt = "\n\n".join(conversation_parts)
+ files_arg = all_file_paths if all_file_paths else None
- if request.model:
- try:
- response = await gemini_client.generate_content(message=final_prompt, model=request.model.value, files=None)
- return convert_to_openai_format(response.text, request.model.value, is_stream)
- except Exception as e:
- logger.error(f"Error in /v1/chat/completions endpoint: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Error processing chat completion: {str(e)}")
- else:
- raise HTTPException(status_code=400, detail="Model not specified in the request.")
+ try:
+ response = await gemini_client.generate_content(
+ message=final_prompt,
+ model=model_value,
+ files=files_arg,
+ )
+
+ images = await serialize_response_images(
+ response, gemini_cookies=_get_cookies(gemini_client)
+ )
+
+ if is_stream:
+ return StreamingResponse(
+ _stream_response(response.text, model_value, images),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+ return _to_openai_format(response.text, model_value, images, is_stream)
+
+ except Exception as e:
+ err_str = str(e)
+ err_lower = err_str.lower()
+ notifier = TelegramNotifier.get_instance()
+ if "auth" in err_lower or "cookie" in err_lower:
+ logger.error(f"[chat/completions] Auth error: {e}")
+ await notifier.notify_error("auth", "Authentication failed", "/v1/chat/completions", err_str)
+ raise HTTPException(status_code=401, detail=f"Gemini authentication failed: {err_str}")
+ elif "zombie" in err_lower or "parse" in err_lower or "stalled" in err_lower:
+ logger.error(f"[chat/completions] Stream error after retries (model={model_value}): {e}")
+ await notifier.notify_error("503", "Stream temporarily unavailable", "/v1/chat/completions", err_str)
+ raise HTTPException(status_code=503, detail="Gemini stream temporarily unavailable β please retry")
+ else:
+ logger.error(f"[chat/completions] Unexpected error (model={model_value}): {e}", exc_info=True)
+ await notifier.notify_error("500", "Unexpected error", "/v1/chat/completions", err_str)
+ raise HTTPException(status_code=500, detail=f"Error processing chat completion: {err_str}")
+
+ finally:
+ # Clean up temp files created from base64/URL image inputs
+ cleanup_temp_files(temp_file_paths)
diff --git a/src/app/endpoints/files.py b/src/app/endpoints/files.py
new file mode 100644
index 00000000..d6645215
--- /dev/null
+++ b/src/app/endpoints/files.py
@@ -0,0 +1,113 @@
+# src/app/endpoints/files.py
+"""
+File upload endpoint β allows clients to upload images or PDFs and receive
+a file_id that can be referenced in subsequent /gemini or /v1/chat/completions
+requests via the `files` field.
+
+Compatible with the OpenAI Files API surface (subset).
+"""
+
+import hashlib
+import time
+from pathlib import Path
+
+from fastapi import APIRouter, File, HTTPException, UploadFile
+
+from app.logger import logger
+from app.utils.image_utils import ALLOWED_MIME_TYPES, get_temp_dir
+
+router = APIRouter()
+
+_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
+
+_MIME_TO_EXT: dict[str, str] = {
+ "image/jpeg": ".jpg",
+ "image/jpg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "application/pdf": ".pdf",
+}
+
+
+@router.post("/v1/files")
+async def upload_file(file: UploadFile = File(...)):
+ """
+ Upload an image or PDF file.
+
+ Returns a ``file_id`` (and ``local_path``) that you can pass to:
+ - ``POST /gemini`` β ``files: [""]``
+ - ``POST /v1/chat/completions`` β content part ``{"type": "image_url", "image_url": {"url": "file://"}}``
+ """
+ content = await file.read()
+
+ if len(content) > _MAX_FILE_SIZE:
+ raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
+
+ # Determine content type β prefer header, fall back to filename extension
+ content_type = (file.content_type or "").split(";")[0].strip()
+ if content_type and content_type not in ALLOWED_MIME_TYPES:
+ raise HTTPException(
+ status_code=415,
+ detail=f"Unsupported file type: {content_type}. Allowed: {sorted(ALLOWED_MIME_TYPES)}",
+ )
+
+ # Derive file extension
+ if content_type and content_type in _MIME_TO_EXT:
+ ext = _MIME_TO_EXT[content_type]
+ else:
+ ext = Path(file.filename or "").suffix or ".bin"
+
+ # Unique, deterministic-ish file ID
+ file_hash = hashlib.md5(content).hexdigest()[:8]
+ timestamp = int(time.time() * 1000)
+ file_id = f"file_{timestamp}_{file_hash}{ext}"
+
+ dest = get_temp_dir() / file_id
+ dest.write_bytes(content)
+ logger.info(f"File uploaded: {file_id} ({len(content)} bytes, type={content_type})")
+
+ return {
+ "id": file_id,
+ "object": "file",
+ "bytes": len(content),
+ "filename": file.filename,
+ "content_type": content_type,
+ "purpose": "vision",
+ "local_path": str(dest),
+ }
+
+
+@router.get("/v1/files/{file_id}")
+async def get_file_info(file_id: str):
+ """Return metadata for a previously uploaded file."""
+ # Sanitize: prevent path traversal
+ if "/" in file_id or "\\" in file_id or ".." in file_id:
+ raise HTTPException(status_code=400, detail="Invalid file_id")
+
+ dest = get_temp_dir() / file_id
+ if not dest.exists():
+ raise HTTPException(status_code=404, detail=f"File not found: {file_id}")
+
+ return {
+ "id": file_id,
+ "object": "file",
+ "bytes": dest.stat().st_size,
+ "local_path": str(dest),
+ }
+
+
+@router.delete("/v1/files/{file_id}")
+async def delete_file(file_id: str):
+ """Delete a previously uploaded file."""
+ if "/" in file_id or "\\" in file_id or ".." in file_id:
+ raise HTTPException(status_code=400, detail="Invalid file_id")
+
+ dest = get_temp_dir() / file_id
+ if not dest.exists():
+ raise HTTPException(status_code=404, detail=f"File not found: {file_id}")
+
+ dest.unlink()
+ logger.info(f"File deleted: {file_id}")
+ return {"id": file_id, "object": "file", "deleted": True}
diff --git a/src/app/endpoints/gemini.py b/src/app/endpoints/gemini.py
index c2036e64..82a90a50 100644
--- a/src/app/endpoints/gemini.py
+++ b/src/app/endpoints/gemini.py
@@ -1,33 +1,80 @@
# src/app/endpoints/gemini.py
+from pathlib import Path
+from typing import List, Optional, Union
+
from fastapi import APIRouter, HTTPException
+
from app.logger import logger
-from schemas.request import GeminiRequest
-from app.services.gemini_client import get_gemini_client, GeminiClientNotInitializedError
+from app.services.gemini_client import GeminiClientNotInitializedError, get_gemini_client
+from app.services.telegram_notifier import TelegramNotifier
from app.services.session_manager import get_gemini_chat_manager
-
-from pathlib import Path
-from typing import Union, List, Optional
+from app.utils.image_utils import cleanup_temp_files, serialize_response_images
+from schemas.request import GeminiRequest
router = APIRouter()
+
+def _get_cookies(gemini_client) -> dict:
+ """Extract session cookies from the underlying Gemini web client."""
+ try:
+ return dict(gemini_client.client.cookies)
+ except Exception:
+ return {}
+
+
@router.post("/gemini")
async def gemini_generate(request: GeminiRequest):
+ """
+ Stateless content generation.
+
+ Response includes:
+ - ``response``: generated text
+ - ``images``: list of web/generated images (URL + base64), if any
+ - ``thoughts``: chain-of-thought text (thinking models only), if any
+ """
try:
gemini_client = get_gemini_client()
except GeminiClientNotInitializedError as e:
raise HTTPException(status_code=503, detail=str(e))
+ file_paths: List[Path] = [Path(f) for f in request.files] if request.files else []
+
try:
- # Use the value attribute for the model (since GeminiRequest.model is an Enum)
- files: Optional[List[Union[str, Path]]] = [Path(f) for f in request.files] if request.files else None
- response = await gemini_client.generate_content(request.message, request.model.value, files=files)
- return {"response": response.text}
+ response = await gemini_client.generate_content(
+ request.message, request.model.value, files=file_paths or None
+ )
+
+ images = await serialize_response_images(response, gemini_cookies=_get_cookies(gemini_client))
+
+ result: dict = {"response": response.text}
+ if images:
+ result["images"] = images
+ if response.thoughts:
+ result["thoughts"] = response.thoughts
+ return result
+
except Exception as e:
logger.error(f"Error in /gemini endpoint: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Error generating content: {str(e)}")
+ err_str = str(e)
+ err_lower = err_str.lower()
+ notifier = TelegramNotifier.get_instance()
+ if "auth" in err_lower or "cookie" in err_lower:
+ await notifier.notify_error("auth", "Authentication failed", "/gemini", err_str)
+ else:
+ await notifier.notify_error("500", "Unexpected error", "/gemini", err_str)
+ raise HTTPException(status_code=500, detail=f"Error generating content: {err_str}")
+
@router.post("/gemini-chat")
async def gemini_chat(request: GeminiRequest):
+ """
+ Stateful chat with persistent session context.
+
+ Response includes:
+ - ``response``: generated text
+ - ``images``: list of web/generated images (URL + base64), if any
+ - ``thoughts``: chain-of-thought text (thinking models only), if any
+ """
try:
gemini_client = get_gemini_client()
except GeminiClientNotInitializedError as e:
@@ -36,9 +83,26 @@ async def gemini_chat(request: GeminiRequest):
session_manager = get_gemini_chat_manager()
if not session_manager:
raise HTTPException(status_code=503, detail="Session manager is not initialized.")
+
try:
response = await session_manager.get_response(request.model, request.message, request.files)
- return {"response": response.text}
+
+ images = await serialize_response_images(response, gemini_cookies=_get_cookies(gemini_client))
+
+ result: dict = {"response": response.text}
+ if images:
+ result["images"] = images
+ if response.thoughts:
+ result["thoughts"] = response.thoughts
+ return result
+
except Exception as e:
logger.error(f"Error in /gemini-chat endpoint: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Error in chat: {str(e)}")
+ err_str = str(e)
+ err_lower = err_str.lower()
+ notifier = TelegramNotifier.get_instance()
+ if "auth" in err_lower or "cookie" in err_lower:
+ await notifier.notify_error("auth", "Authentication failed", "/gemini-chat", err_str)
+ else:
+ await notifier.notify_error("500", "Unexpected error", "/gemini-chat", err_str)
+ raise HTTPException(status_code=500, detail=f"Error in chat: {err_str}")
diff --git a/src/app/endpoints/init.py b/src/app/endpoints/init.py
index 4b9934f0..d8e06e56 100644
--- a/src/app/endpoints/init.py
+++ b/src/app/endpoints/init.py
@@ -3,5 +3,7 @@
from .gemini import router as gemini_router
from .chat import router as chat_router
from .google_generative import router as google_generative_router
+from .files import router as files_router
+from .responses import router as responses_router
-__all__ = ["gemini_router", "chat_router", "google_generative_router"]
+__all__ = ["gemini_router", "chat_router", "google_generative_router", "files_router", "responses_router"]
diff --git a/src/app/endpoints/responses.py b/src/app/endpoints/responses.py
new file mode 100644
index 00000000..5cd1e610
--- /dev/null
+++ b/src/app/endpoints/responses.py
@@ -0,0 +1,308 @@
+# src/app/endpoints/responses.py
+"""
+OpenAI Responses API endpoint β POST /v1/responses
+
+Implements the subset of the Responses API that Home Assistant uses when sending
+images via the ai_task / openai_conversation integration.
+
+Request format (HA sends):
+ {
+ "model": "gemini-3-pro-image-preview",
+ "input": [
+ {"type": "message", "role": "developer", "content": "You are helpful"},
+ {"type": "message", "role": "user", "content": [
+ {"type": "input_text", "text": "What do you see?"},
+ {"type": "input_image", "image_url": "data:image/jpeg;base64,...", "detail": "auto"}
+ ]}
+ ],
+ "instructions": "Optional system prompt shorthand",
+ "stream": true,
+ "store": false,
+ "max_output_tokens": 150
+ }
+
+Streaming response format (SSE):
+ response.created β response.output_item.added β response.content_part.added
+ β response.output_text.delta (one or more) β response.output_text.done
+ β response.output_item.done β response.completed
+"""
+
+import json
+import time
+import uuid
+from pathlib import Path
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+
+from app.logger import logger
+from app.services.gemini_client import GeminiClientNotInitializedError, get_gemini_client
+from app.utils.image_utils import (
+ cleanup_temp_files,
+ get_temp_dir,
+ serialize_response_images,
+)
+
+# Reuse model resolution and content extraction from chat.py
+from app.endpoints.chat import _resolve_model, _extract_multimodal_content, _get_cookies
+
+router = APIRouter()
+
+
+# ---------------------------------------------------------------------------
+# Response object builders
+# ---------------------------------------------------------------------------
+
+def _make_response_id() -> str:
+ return f"resp_{uuid.uuid4().hex[:24]}"
+
+
+def _make_message_id() -> str:
+ return f"msg_{uuid.uuid4().hex[:24]}"
+
+
+def _build_response_base(resp_id: str, model_value: str, status: str, output: list) -> dict:
+ return {
+ "id": resp_id,
+ "object": "response",
+ "created_at": int(time.time()),
+ "model": model_value,
+ "output": output,
+ "status": status,
+ "usage": {
+ "input_tokens": 0,
+ "output_tokens": 0,
+ "total_tokens": 0,
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Streaming SSE helpers
+# ---------------------------------------------------------------------------
+
+def _sse(event: str, data: dict) -> str:
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n"
+
+
+async def _stream_responses_api(text: str, model_value: str, images: list):
+ """
+ Emit the full OpenAI Responses API SSE event sequence for a completed response.
+
+ Since gemini-webapi returns the full text at once (not token-by-token), we
+ emit a single delta containing all text, then close the stream properly so
+ HA parses the response correctly.
+ """
+ resp_id = _make_response_id()
+ msg_id = _make_message_id()
+ created = int(time.time())
+
+ # Append image markdown to text if images were generated
+ content_text = text
+ if images:
+ md_links = "\n".join(f"![{img['title']}]({img['url']})" for img in images)
+ content_text = f"{text}\n\n{md_links}".strip()
+
+ # 1. response.created
+ yield _sse("response.created", {
+ "type": "response.created",
+ "response": _build_response_base(resp_id, model_value, "in_progress", []),
+ })
+
+ # 2. response.output_item.added
+ yield _sse("response.output_item.added", {
+ "type": "response.output_item.added",
+ "output_index": 0,
+ "item": {
+ "type": "message",
+ "id": msg_id,
+ "role": "assistant",
+ "status": "in_progress",
+ "content": [],
+ },
+ })
+
+ # 3. response.content_part.added
+ yield _sse("response.content_part.added", {
+ "type": "response.content_part.added",
+ "output_index": 0,
+ "content_index": 0,
+ "part": {"type": "output_text", "text": "", "annotations": []},
+ })
+
+ # 4. response.output_text.delta (single chunk β full text)
+ yield _sse("response.output_text.delta", {
+ "type": "response.output_text.delta",
+ "output_index": 0,
+ "content_index": 0,
+ "delta": content_text,
+ })
+
+ # 5. response.output_text.done
+ yield _sse("response.output_text.done", {
+ "type": "response.output_text.done",
+ "output_index": 0,
+ "content_index": 0,
+ "text": content_text,
+ })
+
+ # 6. response.output_item.done
+ completed_item = {
+ "type": "message",
+ "id": msg_id,
+ "role": "assistant",
+ "status": "completed",
+ "content": [{"type": "output_text", "text": content_text, "annotations": []}],
+ }
+ yield _sse("response.output_item.done", {
+ "type": "response.output_item.done",
+ "output_index": 0,
+ "item": completed_item,
+ })
+
+ # 7. response.completed
+ completed_response = _build_response_base(resp_id, model_value, "completed", [completed_item])
+ if images:
+ completed_response["images"] = images # extension field
+ yield _sse("response.completed", {
+ "type": "response.completed",
+ "response": completed_response,
+ })
+
+
+# ---------------------------------------------------------------------------
+# Main endpoint
+# ---------------------------------------------------------------------------
+
+@router.post("/v1/responses")
+async def create_response(request: dict):
+ """
+ OpenAI Responses API β used by Home Assistant's openai_conversation integration
+ when sending camera images for analysis via the ai_task / AI task agent.
+
+ Supports:
+ - ``input_text`` and ``input_image`` content parts (Responses API format)
+ - ``text`` and ``image_url`` content parts (Chat Completions format, for compatibility)
+ - Base64 data URIs, public image URLs, and ``file://`` uploaded file references
+ - ``instructions`` field as system prompt shorthand
+ - Streaming (``stream: true``) with full SSE event sequence
+ - Any model name β unknown names are auto-mapped to the closest Gemini model
+ """
+ try:
+ gemini_client = get_gemini_client()
+ except GeminiClientNotInitializedError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+
+ # ββ Resolve model ββββββββββββββββββββββββββββββββββββββββββββββ
+ gemini_model = _resolve_model(request.get("model"))
+ model_value = gemini_model.value
+ is_stream = bool(request.get("stream", False))
+
+ # ββ Parse input array ββββββββββββββββββββββββββββββββββββββββββ
+ input_items = request.get("input", [])
+ if not input_items and not request.get("instructions"):
+ raise HTTPException(status_code=400, detail="No input provided.")
+
+ conversation_parts: List[str] = []
+ all_file_paths: List[Path] = []
+ temp_file_paths: List[Path] = []
+
+ # Optional top-level system prompt shorthand
+ instructions = request.get("instructions", "")
+ if instructions:
+ conversation_parts.append(f"System: {instructions}")
+
+ for item in input_items:
+ if not isinstance(item, dict):
+ continue
+
+ # Only handle message items (ignore function_call, etc.)
+ if item.get("type") != "message":
+ continue
+
+ role = item.get("role", "user")
+ raw_content = item.get("content", "")
+
+ text, file_paths = await _extract_multimodal_content(raw_content)
+
+ # Track temp files for cleanup
+ for fp in file_paths:
+ if str(fp).startswith(str(get_temp_dir())):
+ temp_file_paths.append(fp)
+ all_file_paths.extend(file_paths)
+
+ if not text:
+ continue
+
+ # Map role names β Responses API uses "developer" for system
+ if role in ("system", "developer"):
+ conversation_parts.append(f"System: {text}")
+ elif role == "user":
+ conversation_parts.append(f"User: {text}")
+ elif role == "assistant":
+ conversation_parts.append(f"Assistant: {text}")
+
+ if not conversation_parts:
+ raise HTTPException(status_code=400, detail="No valid messages found in input.")
+
+ final_prompt = "\n\n".join(conversation_parts)
+ files_arg = all_file_paths if all_file_paths else None
+
+ try:
+ response = await gemini_client.generate_content(
+ message=final_prompt,
+ model=model_value,
+ files=files_arg,
+ )
+
+ images = await serialize_response_images(
+ response, gemini_cookies=_get_cookies(gemini_client)
+ )
+
+ if is_stream:
+ return StreamingResponse(
+ _stream_responses_api(response.text, model_value, images),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+ # Non-streaming response
+ resp_id = _make_response_id()
+ msg_id = _make_message_id()
+ content_text = response.text
+ if images:
+ md_links = "\n".join(f"![{img['title']}]({img['url']})" for img in images)
+ content_text = f"{content_text}\n\n{md_links}".strip()
+
+ result = _build_response_base(
+ resp_id, model_value, "completed",
+ [{
+ "type": "message",
+ "id": msg_id,
+ "role": "assistant",
+ "status": "completed",
+ "content": [{"type": "output_text", "text": content_text, "annotations": []}],
+ }]
+ )
+ if images:
+ result["images"] = images
+ if response.thoughts:
+ result["thoughts"] = response.thoughts
+ return result
+
+ except Exception as e:
+ err_str = str(e)
+ err_lower = err_str.lower()
+ if "auth" in err_lower or "cookie" in err_lower:
+ logger.error(f"[/v1/responses] Auth error: {e}")
+ raise HTTPException(status_code=401, detail=f"Gemini authentication failed: {err_str}")
+ elif "zombie" in err_lower or "parse" in err_lower or "stalled" in err_lower:
+ logger.error(f"[/v1/responses] Stream error after retries (model={model_value}): {e}")
+ raise HTTPException(status_code=503, detail="Gemini stream temporarily unavailable β please retry")
+ else:
+ logger.error(f"[/v1/responses] Unexpected error (model={model_value}): {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Error: {err_str}")
+
+ finally:
+ cleanup_temp_files(temp_file_paths)
diff --git a/src/app/main.py b/src/app/main.py
index 7ce2f769..c65e7196 100644
--- a/src/app/main.py
+++ b/src/app/main.py
@@ -1,14 +1,25 @@
# src/app/main.py
-from fastapi import FastAPI
+import logging
+from pathlib import Path
from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
-from app.services.gemini_client import get_gemini_client, init_gemini_client, GeminiClientNotInitializedError
+from app.services.gemini_client import get_gemini_client, init_gemini_client, GeminiClientNotInitializedError, start_cookie_persister, stop_cookie_persister
from app.services.session_manager import init_session_managers
+from app.services.log_broadcaster import SSELogBroadcaster, BroadcastLogHandler
+from app.services.stats_collector import StatsCollector
from app.logger import logger
# Import endpoint routers
-from app.endpoints import gemini, chat, google_generative
+from app.endpoints import gemini, chat, google_generative, files, responses
+from app.endpoints import admin, admin_api
+
+_SRC_DIR = Path(__file__).resolve().parent.parent # points to src/
+
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -16,6 +27,14 @@ async def lifespan(app: FastAPI):
Application lifespan manager.
Initializes services on startup.
"""
+ # Initialize log broadcaster and attach handler to root logger only.
+ # Child loggers (app, uvicorn, etc.) propagate to root by default,
+ # so attaching to root is sufficient and avoids duplicate entries.
+ broadcaster = SSELogBroadcaster.get_instance()
+ handler = BroadcastLogHandler(broadcaster)
+ handler.setLevel(logging.INFO)
+ logging.getLogger().addHandler(handler)
+
# Try to get the existing client first
client_initialized = False
try:
@@ -40,14 +59,16 @@ async def lifespan(app: FastAPI):
try:
get_gemini_client()
init_session_managers()
+ start_cookie_persister()
logger.info("Session managers initialized for WebAI-to-API.")
except GeminiClientNotInitializedError as e:
logger.warning(f"Session managers not initialized: {e}")
yield
- # Shutdown logic: No explicit client closing is needed anymore.
- # The underlying HTTPX client manages its connection pool automatically.
+ # Cleanup on shutdown
+ stop_cookie_persister()
+ logging.getLogger().removeHandler(handler)
logger.info("Application shutdown complete.")
app = FastAPI(lifespan=lifespan)
@@ -64,3 +85,27 @@ async def lifespan(app: FastAPI):
app.include_router(gemini.router)
app.include_router(chat.router)
app.include_router(google_generative.router)
+app.include_router(files.router)
+app.include_router(responses.router)
+
+# Register admin routers
+app.include_router(admin.router)
+app.include_router(admin_api.router)
+
+# Mount static files for admin UI
+app.mount("/static", StaticFiles(directory=str(_SRC_DIR / "static")), name="static")
+
+
+@app.get("/")
+async def root():
+ return RedirectResponse(url="/admin")
+
+
+# Stats middleware - track API requests (skip static/admin)
+@app.middleware("http")
+async def stats_middleware(request: Request, call_next):
+ response = await call_next(request)
+ path = request.url.path
+ if not path.startswith("/static") and not path.startswith("/admin") and not path.startswith("/api/admin"):
+ StatsCollector.get_instance().record_request(path, response.status_code)
+ return response
diff --git a/src/app/services/curl_parser.py b/src/app/services/curl_parser.py
new file mode 100644
index 00000000..20532bd8
--- /dev/null
+++ b/src/app/services/curl_parser.py
@@ -0,0 +1,95 @@
+# src/app/services/curl_parser.py
+import re
+import shlex
+from typing import Optional, Dict
+
+
+class CurlParseResult:
+ """Result of parsing a cURL command or cookie string."""
+
+ def __init__(self):
+ self.secure_1psid: Optional[str] = None
+ self.secure_1psidts: Optional[str] = None
+ self.all_cookies: Dict[str, str] = {}
+ self.url: Optional[str] = None
+ self.errors: list[str] = []
+
+ @property
+ def is_valid(self) -> bool:
+ return bool(self.secure_1psid and self.secure_1psidts)
+
+
+def parse_cookies_from_string(cookie_string: str) -> Dict[str, str]:
+ """Parse a semicolon-separated cookie string into a dict."""
+ cookies = {}
+ for pair in cookie_string.split(";"):
+ pair = pair.strip()
+ if "=" in pair:
+ name, _, value = pair.partition("=")
+ cookies[name.strip()] = value.strip()
+ return cookies
+
+
+def parse_curl_command(raw_input: str) -> CurlParseResult:
+ """
+ Parse either a full cURL command or a raw cookie header string.
+ Extracts __Secure-1PSID and __Secure-1PSIDTS values.
+
+ Supports:
+ - Full cURL from Chrome/Firefox DevTools "Copy as cURL"
+ - Raw Cookie header value (semicolon-separated pairs)
+ """
+ result = CurlParseResult()
+ text = raw_input.strip()
+
+ if not text:
+ result.errors.append("Empty input")
+ return result
+
+ if text.lower().startswith("curl "):
+ # Normalize line continuations
+ text_clean = text.replace("\\\n", " ").replace("\\\r\n", " ")
+
+ # Try shlex tokenization first
+ try:
+ tokens = shlex.split(text_clean)
+ except ValueError:
+ tokens = []
+ result.errors.append("shlex parsing failed, using regex fallback")
+
+ # Find cookie header via token pairs (-H 'cookie: ...')
+ for i, token in enumerate(tokens):
+ if token in ("-H", "--header") and i + 1 < len(tokens):
+ header_val = tokens[i + 1]
+ if header_val.lower().startswith("cookie:"):
+ cookie_str = header_val[len("cookie:"):].strip()
+ result.all_cookies = parse_cookies_from_string(cookie_str)
+
+ # Regex fallback if token parsing missed the cookie header
+ if not result.all_cookies:
+ match = re.search(
+ r"-H\s+['\"]cookie:\s*([^'\"]+)['\"]",
+ text_clean,
+ re.IGNORECASE,
+ )
+ if match:
+ result.all_cookies = parse_cookies_from_string(match.group(1))
+
+ # Extract URL
+ url_match = re.search(r"curl\s+['\"]?(https?://[^\s'\"]+)", text_clean)
+ if url_match:
+ result.url = url_match.group(1)
+ else:
+ # Assume raw cookie string
+ result.all_cookies = parse_cookies_from_string(text)
+
+ # Extract target cookies
+ result.secure_1psid = result.all_cookies.get("__Secure-1PSID")
+ result.secure_1psidts = result.all_cookies.get("__Secure-1PSIDTS")
+
+ if not result.secure_1psid:
+ result.errors.append("__Secure-1PSID cookie not found")
+ if not result.secure_1psidts:
+ result.errors.append("__Secure-1PSIDTS cookie not found")
+
+ return result
diff --git a/src/app/services/gemini_client.py b/src/app/services/gemini_client.py
index 37cb027e..2defe8bf 100644
--- a/src/app/services/gemini_client.py
+++ b/src/app/services/gemini_client.py
@@ -1,6 +1,7 @@
# src/app/services/gemini_client.py
+import asyncio
from models.gemini import MyGeminiClient
-from app.config import CONFIG
+from app.config import CONFIG, write_config
from app.logger import logger
from app.utils.browser import get_cookie_from_browser
@@ -16,14 +17,17 @@ class GeminiClientNotInitializedError(Exception):
# Global variable to store the Gemini client instance
_gemini_client = None
_initialization_error = None
+_error_code = None # "auth_expired", "no_cookies", "network", "disabled", "unknown"
+_persist_task: asyncio.Task = None # Background task for persisting rotated cookies
async def init_gemini_client() -> bool:
"""
Initialize and set up the Gemini client based on the configuration.
Returns True on success, False on failure.
"""
- global _gemini_client, _initialization_error
+ global _gemini_client, _initialization_error, _error_code
_initialization_error = None
+ _error_code = None
if CONFIG.getboolean("EnabledAI", "gemini", fallback=True):
try:
@@ -45,28 +49,35 @@ async def init_gemini_client() -> bool:
logger.info("Gemini client initialized successfully.")
return True
else:
- error_msg = "Gemini cookies not found. Please provide cookies in config.conf or ensure browser is logged in."
- logger.error(error_msg)
- _initialization_error = error_msg
+ _error_code = "no_cookies"
+ _initialization_error = "Gemini cookies not found."
+ logger.error(_initialization_error)
return False
except AuthError as e:
- error_msg = f"Gemini authentication failed: {e}. This usually means cookies are expired or invalid."
- logger.error(error_msg)
+ _error_code = "auth_expired"
+ _initialization_error = str(e)
+ logger.error(f"Gemini authentication failed: {e}")
+ _gemini_client = None
+ return False
+
+ except (ConnectionError, OSError, TimeoutError) as e:
+ _error_code = "network"
+ _initialization_error = str(e)
+ logger.error(f"Network error initializing Gemini client: {e}")
_gemini_client = None
- _initialization_error = error_msg
return False
except Exception as e:
- error_msg = f"Unexpected error initializing Gemini client: {e}"
- logger.error(error_msg, exc_info=True)
+ _error_code = "unknown"
+ _initialization_error = str(e)
+ logger.error(f"Unexpected error initializing Gemini client: {e}", exc_info=True)
_gemini_client = None
- _initialization_error = error_msg
return False
else:
- error_msg = "Gemini client is disabled in config."
- logger.info(error_msg)
- _initialization_error = error_msg
+ _error_code = "disabled"
+ _initialization_error = "Gemini client is disabled in config."
+ logger.info(_initialization_error)
return False
@@ -82,3 +93,73 @@ def get_gemini_client():
raise GeminiClientNotInitializedError(error_detail)
return _gemini_client
+
+def get_client_status() -> dict:
+ """Return the current status of the Gemini client for the admin UI."""
+ return {
+ "initialized": _gemini_client is not None,
+ "error": _initialization_error,
+ "error_code": _error_code,
+ }
+
+
+async def _persist_cookies_loop():
+ """
+ Background task that watches for cookie rotation by gemini-webapi's auto_refresh
+ mechanism and persists any updated values back to config.conf.
+
+ The library rotates __Secure-1PSIDTS every ~9 minutes in-memory only.
+ Without this task, a server restart would reload the original (expired) cookies.
+ """
+ # Wait one full refresh cycle before first check so the library has time to rotate
+ await asyncio.sleep(600)
+ while True:
+ try:
+ if _gemini_client is not None:
+ # Access the underlying WebGeminiClient cookies dict
+ client_cookies = _gemini_client.client.cookies
+ new_1psid = client_cookies.get("__Secure-1PSID")
+ new_1psidts = client_cookies.get("__Secure-1PSIDTS")
+
+ current_1psid = CONFIG["Cookies"].get("gemini_cookie_1PSID", "")
+ current_1psidts = CONFIG["Cookies"].get("gemini_cookie_1PSIDTS", "")
+
+ changed = False
+ if new_1psid and new_1psid != current_1psid:
+ CONFIG["Cookies"]["gemini_cookie_1PSID"] = new_1psid
+ changed = True
+ logger.info("__Secure-1PSID rotated β will persist to config.")
+ if new_1psidts and new_1psidts != current_1psidts:
+ CONFIG["Cookies"]["gemini_cookie_1PSIDTS"] = new_1psidts
+ changed = True
+ logger.info("__Secure-1PSIDTS rotated β will persist to config.")
+
+ if changed:
+ write_config(CONFIG)
+ logger.info("Rotated Gemini cookies persisted to config.conf.")
+ except asyncio.CancelledError:
+ raise
+ except Exception as e:
+ logger.warning(f"Cookie persist check failed: {e}")
+
+ await asyncio.sleep(600) # Re-check every 10 minutes
+
+
+def start_cookie_persister() -> asyncio.Task:
+ """Start the background cookie-persist task. Safe to call multiple times."""
+ global _persist_task
+ if _persist_task is not None and not _persist_task.done():
+ return _persist_task
+ _persist_task = asyncio.create_task(_persist_cookies_loop())
+ logger.info("Cookie persist task started (checks every 10 min).")
+ return _persist_task
+
+
+def stop_cookie_persister():
+ """Cancel the cookie persister task on shutdown."""
+ global _persist_task
+ if _persist_task is not None and not _persist_task.done():
+ _persist_task.cancel()
+ logger.info("Cookie persist task stopped.")
+ _persist_task = None
+
diff --git a/src/app/services/log_broadcaster.py b/src/app/services/log_broadcaster.py
new file mode 100644
index 00000000..067dfd7d
--- /dev/null
+++ b/src/app/services/log_broadcaster.py
@@ -0,0 +1,104 @@
+# src/app/services/log_broadcaster.py
+import asyncio
+import logging
+from collections import deque
+from datetime import datetime
+from typing import AsyncGenerator, Optional
+
+
+class LogEntry:
+ """Structured log entry for the admin UI."""
+
+ __slots__ = ("timestamp", "level", "name", "message", "id")
+
+ def __init__(self, record: logging.LogRecord, entry_id: int):
+ self.timestamp = datetime.fromtimestamp(record.created).isoformat()
+ self.level = record.levelname
+ self.name = record.name
+ self.message = record.getMessage()
+ self.id = entry_id
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "timestamp": self.timestamp,
+ "level": self.level,
+ "logger": self.name,
+ "message": self.message,
+ }
+
+
+class SSELogBroadcaster:
+ """
+ Singleton that captures log records and broadcasts them to SSE clients.
+ Uses a ring buffer (deque) for recent entries and asyncio.Event to wake subscribers.
+ """
+
+ _instance: Optional["SSELogBroadcaster"] = None
+
+ def __init__(self, max_entries: int = 500):
+ self._buffer: deque[LogEntry] = deque(maxlen=max_entries)
+ self._counter: int = 0
+ self._event = asyncio.Event()
+ self._clients: int = 0
+
+ @classmethod
+ def get_instance(cls) -> "SSELogBroadcaster":
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def push(self, record: logging.LogRecord) -> None:
+ """Called by the logging handler (may be from any thread)."""
+ self._counter += 1
+ entry = LogEntry(record, self._counter)
+ self._buffer.append(entry)
+ try:
+ loop = asyncio.get_running_loop()
+ loop.call_soon_threadsafe(self._event.set)
+ except RuntimeError:
+ pass
+
+ def get_recent(self, count: int = 50) -> list[dict]:
+ """Return the most recent N log entries as dicts."""
+ entries = list(self._buffer)[-count:]
+ return [e.to_dict() for e in entries]
+
+ async def subscribe(self, last_id: int = 0) -> AsyncGenerator[dict, None]:
+ """Async generator that yields new log entries as they arrive."""
+ self._clients += 1
+ try:
+ # Replay buffered entries newer than last_id
+ for entry in self._buffer:
+ if entry.id > last_id:
+ yield entry.to_dict()
+ last_id = entry.id
+
+ # Live tail
+ while True:
+ self._event.clear()
+ await self._event.wait()
+ for entry in self._buffer:
+ if entry.id > last_id:
+ yield entry.to_dict()
+ last_id = entry.id
+ finally:
+ self._clients -= 1
+
+ @property
+ def client_count(self) -> int:
+ return self._clients
+
+
+class BroadcastLogHandler(logging.Handler):
+ """Logging handler that forwards records to the SSELogBroadcaster."""
+
+ def __init__(self, broadcaster: SSELogBroadcaster):
+ super().__init__()
+ self.broadcaster = broadcaster
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ self.broadcaster.push(record)
+ except Exception:
+ self.handleError(record)
diff --git a/src/app/services/stats_collector.py b/src/app/services/stats_collector.py
new file mode 100644
index 00000000..bdc40486
--- /dev/null
+++ b/src/app/services/stats_collector.py
@@ -0,0 +1,70 @@
+# src/app/services/stats_collector.py
+import time
+import threading
+from typing import Optional
+
+
+class StatsCollector:
+ """Thread-safe singleton for tracking request statistics."""
+
+ _instance: Optional["StatsCollector"] = None
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._start_time = time.time()
+ self._total_requests = 0
+ self._success_count = 0
+ self._error_count = 0
+ self._endpoint_counts: dict[str, int] = {}
+ self._endpoint_success: dict[str, int] = {}
+ self._endpoint_error: dict[str, int] = {}
+ self._endpoint_last_seen: dict[str, float] = {}
+ self._last_request_time: Optional[float] = None
+
+ @classmethod
+ def get_instance(cls) -> "StatsCollector":
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def record_request(self, path: str, status_code: int) -> None:
+ with self._lock:
+ now = time.time()
+ self._total_requests += 1
+ self._last_request_time = now
+ self._endpoint_counts[path] = self._endpoint_counts.get(path, 0) + 1
+ self._endpoint_last_seen[path] = now
+ is_success = 200 <= status_code < 400
+ if is_success:
+ self._success_count += 1
+ self._endpoint_success[path] = self._endpoint_success.get(path, 0) + 1
+ else:
+ self._error_count += 1
+ self._endpoint_error[path] = self._endpoint_error.get(path, 0) + 1
+
+ def get_stats(self) -> dict:
+ with self._lock:
+ uptime_seconds = time.time() - self._start_time
+ hours, remainder = divmod(int(uptime_seconds), 3600)
+ minutes, seconds = divmod(remainder, 60)
+
+ # Build per-endpoint detail
+ endpoints_detail = {}
+ for path, count in self._endpoint_counts.items():
+ endpoints_detail[path] = {
+ "count": count,
+ "success": self._endpoint_success.get(path, 0),
+ "error": self._endpoint_error.get(path, 0),
+ "last_seen": self._endpoint_last_seen.get(path),
+ }
+
+ return {
+ "uptime": f"{hours}h {minutes}m {seconds}s",
+ "uptime_seconds": uptime_seconds,
+ "total_requests": self._total_requests,
+ "success_count": self._success_count,
+ "error_count": self._error_count,
+ "endpoints": {p: d["count"] for p, d in endpoints_detail.items()},
+ "endpoints_detail": endpoints_detail,
+ "last_request_time": self._last_request_time,
+ }
diff --git a/src/app/services/telegram_notifier.py b/src/app/services/telegram_notifier.py
new file mode 100644
index 00000000..c0851a36
--- /dev/null
+++ b/src/app/services/telegram_notifier.py
@@ -0,0 +1,152 @@
+# src/app/services/telegram_notifier.py
+"""
+Telegram notification service for API error alerts.
+
+Sends Telegram messages when critical API errors occur (auth failures,
+service errors). Uses a per-error-type cooldown to prevent notification spam.
+"""
+import time
+from typing import Optional
+
+import httpx
+
+from app.config import CONFIG
+from app.logger import logger
+
+_TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
+
+# Minimum seconds between notifications of the same error type
+_DEFAULT_COOLDOWN = 60
+
+# Only send notifications for these error types by default (auth = cookie expired)
+_DEFAULT_NOTIFY_TYPES = "auth"
+
+
+class TelegramNotifier:
+ """Singleton Telegram alert service."""
+
+ _instance: Optional["TelegramNotifier"] = None
+
+ def __init__(self) -> None:
+ # Last send time per error category (e.g. "auth", "500", "503")
+ self._last_sent: dict[str, float] = {}
+
+ @classmethod
+ def get_instance(cls) -> "TelegramNotifier":
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ # ------------------------------------------------------------------
+ # Config helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _cfg() -> dict:
+ section = CONFIG["Telegram"] if "Telegram" in CONFIG else {}
+ raw_types = section.get("notify_types", _DEFAULT_NOTIFY_TYPES).strip()
+ notify_types = {t.strip() for t in raw_types.split(",") if t.strip()}
+ return {
+ "enabled": str(section.get("enabled", "false")).lower() == "true",
+ "bot_token": section.get("bot_token", "").strip(),
+ "chat_id": section.get("chat_id", "").strip(),
+ "cooldown": int(section.get("cooldown_seconds", _DEFAULT_COOLDOWN)),
+ "notify_types": notify_types,
+ }
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ async def notify_error(
+ self,
+ error_type: str,
+ message: str,
+ endpoint: str = "",
+ detail: str = "",
+ ) -> bool:
+ """
+ Send an error notification to Telegram.
+
+ Parameters
+ ----------
+ error_type:
+ Short identifier used for cooldown tracking, e.g. "auth", "500", "503".
+ message:
+ Human-readable summary shown in the notification title.
+ endpoint:
+ API path where the error occurred (optional).
+ detail:
+ Additional error detail / exception message (optional).
+
+ Returns True if a message was sent, False if skipped or failed.
+ """
+ cfg = self._cfg()
+ if not cfg["enabled"]:
+ return False
+ if not cfg["bot_token"] or not cfg["chat_id"]:
+ return False
+
+ # Type filter β only send for configured error types
+ if error_type not in cfg["notify_types"]:
+ return False
+
+ # Cooldown guard
+ now = time.monotonic()
+ last = self._last_sent.get(error_type, 0.0)
+ if now - last < cfg["cooldown"]:
+ return False # Too soon β skip
+
+ text = self._build_message(error_type, message, endpoint, detail)
+ sent = await self._send(cfg["bot_token"], cfg["chat_id"], text)
+ if sent:
+ self._last_sent[error_type] = now
+ return sent
+
+ async def send_test(self, bot_token: str, chat_id: str) -> tuple[bool, str]:
+ """Send a test message using the provided credentials."""
+ text = (
+ "β *WebAI-to-API* β Telegram notifications configured successfully!\n"
+ "You will receive alerts here when API errors occur."
+ )
+ ok = await self._send(bot_token, chat_id, text)
+ return ok, "Message sent successfully." if ok else "Failed to send message β check token and chat_id."
+
+ # ------------------------------------------------------------------
+ # Private helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _build_message(error_type: str, message: str, endpoint: str, detail: str) -> str:
+ icon = {
+ "auth": "π",
+ "503": "β οΈ",
+ "500": "π΄",
+ }.get(error_type, "β")
+
+ lines = [f"{icon} *WebAI-to-API Error*", f"*Type:* {error_type.upper()} | {message}"]
+ if endpoint:
+ lines.append(f"*Endpoint:* `{endpoint}`")
+ if detail:
+ # Truncate long details
+ truncated = detail[:300] + ("β¦" if len(detail) > 300 else "")
+ lines.append(f"*Detail:* {truncated}")
+ return "\n".join(lines)
+
+ @staticmethod
+ async def _send(bot_token: str, chat_id: str, text: str) -> bool:
+ url = _TELEGRAM_API.format(token=bot_token)
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.post(url, json={
+ "chat_id": chat_id,
+ "text": text,
+ "parse_mode": "Markdown",
+ })
+ if resp.status_code == 200 and resp.json().get("ok"):
+ return True
+ logger.warning(f"[TelegramNotifier] Send failed: {resp.status_code} {resp.text[:200]}")
+ return False
+ except Exception as exc:
+ logger.warning(f"[TelegramNotifier] Exception sending message: {exc}")
+ return False
diff --git a/src/app/utils/image_utils.py b/src/app/utils/image_utils.py
new file mode 100644
index 00000000..3921ccc4
--- /dev/null
+++ b/src/app/utils/image_utils.py
@@ -0,0 +1,178 @@
+# src/app/utils/image_utils.py
+"""
+Utilities for image processing: base64 decoding, URL downloading,
+Gemini response serialization, and temp file management.
+"""
+
+import base64
+import hashlib
+import re
+import tempfile
+import time
+from pathlib import Path
+from typing import Optional
+
+import httpx
+
+from app.logger import logger
+
+# ---------------------------------------------------------------------------
+# Temp directory β created once per process lifetime
+# ---------------------------------------------------------------------------
+_TEMP_DIR: Path = Path(tempfile.mkdtemp(prefix="webai_uploads_"))
+
+_MIME_TO_EXT: dict[str, str] = {
+ "image/jpeg": ".jpg",
+ "image/jpg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "application/pdf": ".pdf",
+}
+
+ALLOWED_MIME_TYPES: set[str] = set(_MIME_TO_EXT.keys())
+
+
+def get_temp_dir() -> Path:
+ """Return the shared temp directory for this process."""
+ _TEMP_DIR.mkdir(parents=True, exist_ok=True)
+ return _TEMP_DIR
+
+
+def _unique_name(prefix: str, ext: str) -> str:
+ ts = int(time.time() * 1000)
+ return f"{prefix}_{ts}{ext}"
+
+
+# ---------------------------------------------------------------------------
+# Decode base64 data URI β temp file
+# ---------------------------------------------------------------------------
+def decode_base64_to_tempfile(data_uri: str) -> Path:
+ """
+ Decode a base64 data URI (``data:;base64,``) to a temp file.
+
+ Returns the Path of the saved file.
+ Raises ValueError for invalid format.
+ """
+ match = re.match(r"data:([^;]+);base64,(.+)", data_uri, re.DOTALL)
+ if not match:
+ raise ValueError(f"Invalid data URI: {data_uri[:60]}β¦")
+
+ mime_type = match.group(1).strip()
+ b64_data = match.group(2).strip()
+ ext = _MIME_TO_EXT.get(mime_type, ".bin")
+
+ raw = base64.b64decode(b64_data)
+ dest = get_temp_dir() / _unique_name("b64", ext)
+ dest.write_bytes(raw)
+ logger.debug(f"Decoded base64 β {dest} ({len(raw)} bytes)")
+ return dest
+
+
+# ---------------------------------------------------------------------------
+# Download URL β temp file
+# ---------------------------------------------------------------------------
+async def download_to_tempfile(url: str, cookies: Optional[dict] = None) -> Optional[Path]:
+ """
+ Download an image/file from *url* into a temp file.
+
+ ``cookies`` is forwarded for authenticated Gemini URLs (generated images).
+ Returns the Path on success, None on failure.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=30, cookies=cookies or {}) as client:
+ resp = await client.get(url, follow_redirects=True)
+ resp.raise_for_status()
+
+ content_type = resp.headers.get("content-type", "").split(";")[0].strip()
+ ext = _MIME_TO_EXT.get(content_type, ".jpg")
+ dest = get_temp_dir() / _unique_name("dl", ext)
+ dest.write_bytes(resp.content)
+ logger.debug(f"Downloaded {url} β {dest} ({len(resp.content)} bytes)")
+ return dest
+ except Exception as exc:
+ logger.warning(f"Failed to download {url}: {exc}")
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Fetch image URL β base64 data URI string
+# ---------------------------------------------------------------------------
+async def fetch_image_as_base64(url: str, cookies: Optional[dict] = None) -> str:
+ """
+ Download an image from *url* and return it as a ``data:;base64,`` string.
+
+ Returns empty string on failure.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=30, cookies=cookies or {}) as client:
+ resp = await client.get(url, follow_redirects=True)
+ resp.raise_for_status()
+
+ content_type = resp.headers.get("content-type", "image/png").split(";")[0].strip()
+ b64 = base64.b64encode(resp.content).decode()
+ return f"data:{content_type};base64,{b64}"
+ except Exception as exc:
+ logger.warning(f"Failed to fetch image as base64 from {url}: {exc}")
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Serialize GeminiResponse images β list[dict]
+# ---------------------------------------------------------------------------
+async def serialize_response_images(response, gemini_cookies: Optional[dict] = None) -> list[dict]:
+ """
+ Extract all images from a *GeminiResponse* (ModelOutput) and return a list
+ of dicts suitable for JSON serialization.
+
+ Each dict has:
+ - type: "web_image" | "generated_image"
+ - url: original Gemini URL
+ - base64: data URI (downloaded with auth cookies if needed), empty on failure
+ - title: image title
+ - alt: alt text / description
+ """
+ if not response.candidates:
+ return []
+
+ chosen = response.candidates[response.chosen]
+ result: list[dict] = []
+
+ # Web images β publicly accessible URLs
+ for img in chosen.web_images:
+ b64 = await fetch_image_as_base64(img.url)
+ result.append({
+ "type": "web_image",
+ "url": img.url,
+ "base64": b64,
+ "title": img.title or "[Image]",
+ "alt": img.alt or "",
+ })
+
+ # Generated images β may require auth cookies
+ for img in chosen.generated_images:
+ b64 = await fetch_image_as_base64(img.url, cookies=gemini_cookies)
+ result.append({
+ "type": "generated_image",
+ "url": img.url,
+ "base64": b64,
+ "title": img.title or "[Generated Image]",
+ "alt": img.alt or "",
+ })
+
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Cleanup temp files
+# ---------------------------------------------------------------------------
+def cleanup_temp_files(paths: list[Path]) -> None:
+ """Delete a list of temp files, logging any errors."""
+ for p in paths:
+ try:
+ if p and p.exists() and p.is_relative_to(_TEMP_DIR):
+ p.unlink()
+ logger.debug(f"Cleaned up temp file: {p}")
+ except Exception as exc:
+ logger.warning(f"Failed to delete temp file {p}: {exc}")
diff --git a/src/models/gemini.py b/src/models/gemini.py
index 48fdd563..c840c361 100644
--- a/src/models/gemini.py
+++ b/src/models/gemini.py
@@ -1,12 +1,22 @@
# src/models/gemini.py
+import asyncio
from typing import Optional, List, Union
from pathlib import Path
from gemini_webapi import GeminiClient as WebGeminiClient
from app.config import CONFIG
+from app.logger import logger
+
+# Errors that are transient β gemini-webapi auto-reinits the session after
+# these, so retrying after a short delay usually succeeds.
+_RETRYABLE_KEYWORDS = ("zombie stream", "failed to parse response body", "stalled")
+_MAX_RETRIES = 2
+_RETRY_DELAYS = (3.0, 5.0) # seconds between retry attempts
+
class MyGeminiClient:
"""
- Wrapper for the Gemini Web API client.
+ Wrapper for the Gemini Web API client with automatic retry on
+ transient errors (zombie stream / parse failures).
"""
def __init__(self, secure_1psid: str, secure_1psidts: str, proxy: str | None = None) -> None:
self.client = WebGeminiClient(secure_1psid, secure_1psidts, proxy)
@@ -14,11 +24,32 @@ def __init__(self, secure_1psid: str, secure_1psidts: str, proxy: str | None = N
async def init(self) -> None:
"""Initialize the Gemini client."""
await self.client.init()
+
async def generate_content(self, message: str, model: str, files: Optional[List[Union[str, Path]]] = None):
"""
- Generate content using the Gemini client.
+ Generate content with automatic retry on transient errors.
+ gemini-webapi reinitializes its session after zombie/parse errors
+ (~2-3 s); retrying after that window succeeds in most cases.
"""
- return await self.client.generate_content(message, model=model, files=files)
+ last_exc: Exception | None = None
+ for attempt in range(_MAX_RETRIES + 1):
+ try:
+ return await self.client.generate_content(message, model=model, files=files)
+ except Exception as e:
+ last_exc = e
+ err_lower = str(e).lower()
+ is_retryable = any(kw in err_lower for kw in _RETRYABLE_KEYWORDS)
+ if is_retryable and attempt < _MAX_RETRIES:
+ delay = _RETRY_DELAYS[attempt]
+ logger.warning(
+ f"Gemini transient error (attempt {attempt + 1}/{_MAX_RETRIES + 1},"
+ f" model={model}): {e!r} β retrying in {delay}s"
+ )
+ await asyncio.sleep(delay)
+ continue
+ # Non-retryable or exhausted retries
+ raise
+ raise last_exc # unreachable, satisfies type checkers
async def close(self) -> None:
"""Close the Gemini client."""
diff --git a/src/schemas/request.py b/src/schemas/request.py
index 0eb3662a..3a59a740 100644
--- a/src/schemas/request.py
+++ b/src/schemas/request.py
@@ -1,29 +1,53 @@
# src/schemas/request.py
from enum import Enum
-from typing import List, Optional
+from typing import Any, List, Optional, Union
from pydantic import BaseModel, Field
+
+# ---------------------------------------------------------------------------
+# Multimodal content part schemas (OpenAI vision format)
+# ---------------------------------------------------------------------------
+
+class ImageUrlDetail(BaseModel):
+ """Inner object for image_url content parts."""
+ url: str
+ detail: Optional[str] = "auto"
+
+
+class ContentPart(BaseModel):
+ """A single part of a multimodal message content array."""
+ type: str # "text" | "image_url"
+ text: Optional[str] = None
+ image_url: Optional[ImageUrlDetail] = None
+
+
+# ---------------------------------------------------------------------------
+# Gemini model enum
+# ---------------------------------------------------------------------------
+
class GeminiModels(str, Enum):
"""
- An enumeration of the available Gemini models.
+ Available Gemini models (gemini-webapi >= 1.19.2).
"""
# Gemini 3.0 Series
- PRO_3_0 = "gemini-3.0-pro"
-
- # Gemini 2.5 Series
- PRO_2_5 = "gemini-2.5-pro"
- FLASH_2_5 = "gemini-2.5-flash"
+ PRO = "gemini-3.0-pro"
+ FLASH = "gemini-3.0-flash"
+ FLASH_THINKING = "gemini-3.0-flash-thinking"
class GeminiRequest(BaseModel):
message: str
- model: GeminiModels = Field(default=GeminiModels.FLASH_2_5, description="Model to use for Gemini.")
+ model: GeminiModels = Field(default=GeminiModels.FLASH, description="Model to use for Gemini.")
files: Optional[List[str]] = []
class OpenAIChatRequest(BaseModel):
messages: List[dict]
- model: Optional[GeminiModels] = None
+ # Accept any string β unknown model names are resolved to the closest
+ # GeminiModels value in the endpoint (see _resolve_model in chat.py).
+ # This ensures compatibility with Home Assistant and other OpenAI clients
+ # that send model names like "gemini-3-pro-image-preview".
+ model: Optional[str] = None
stream: Optional[bool] = False
class Part(BaseModel):
diff --git a/src/static/css/admin.css b/src/static/css/admin.css
new file mode 100644
index 00000000..6ea1552f
--- /dev/null
+++ b/src/static/css/admin.css
@@ -0,0 +1,1077 @@
+/* ================================================================
+ WebAI-to-API Admin β Material Design 3 Γ Gemini Theme
+ ================================================================ */
+
+@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
+
+/* ββ Design Tokens ββββββββββββββββββββββββββββββββββββββββββββββββ */
+:root {
+ /* Surface palette (M3 dark) */
+ --md-bg: #0C0C10;
+ --md-surface: #13141B;
+ --md-surface-container-lowest: #0C0C10;
+ --md-surface-container-low: #191A23;
+ --md-surface-container: #1E1F29;
+ --md-surface-container-high: #282934;
+ --md-surface-container-highest:#31323E;
+
+ /* Text */
+ --md-on-surface: #E4E1EC;
+ --md-on-surface-variant: #C9C5D2;
+ --md-outline: #938F9E;
+ --md-outline-variant: #47464F;
+
+ /* Primary β blue (Gemini gradient start) */
+ --md-primary: #A8C7FA;
+ --md-on-primary: #062E6F;
+ --md-primary-container: #0842A0;
+ --md-on-primary-container: #D6E3FF;
+
+ /* Secondary β purple */
+ --md-secondary: #CCC2DC;
+ --md-secondary-container: #4A4458;
+ --md-on-secondary-container: #E8DEF8;
+
+ /* Tertiary β green (success) */
+ --md-tertiary: #81C995;
+ --md-tertiary-container: #00522A;
+ --md-on-tertiary-container: #9DECB0;
+
+ /* Error */
+ --md-error: #FFB4AB;
+ --md-error-container: #8C1D18;
+ --md-on-error-container: #FFDAD6;
+
+ /* Warning */
+ --md-warning: #E6C55A;
+ --md-warning-bg: rgba(230, 197, 90, 0.10);
+
+ /* Compatibility aliases for JS color references */
+ --success: var(--md-tertiary);
+ --error: var(--md-error);
+ --warning: var(--md-warning);
+ --accent: var(--md-primary);
+
+ /* Gemini brand gradient */
+ --gemini-gradient: linear-gradient(135deg, #4285F4 0%, #9368F0 55%, #E98FDA 100%);
+ --gemini-gradient-wide: linear-gradient(270deg, #4285F4, #9368F0, #E98FDA, #9368F0, #4285F4);
+
+ /* Shape (M3 scale) */
+ --shape-xs: 4px;
+ --shape-sm: 8px;
+ --shape-md: 12px;
+ --shape-lg: 16px;
+ --shape-xl: 28px;
+ --shape-full: 9999px;
+
+ /* Elevation tint via primary at opacity */
+ --elev-1: rgba(168, 199, 250, 0.05);
+ --elev-2: rgba(168, 199, 250, 0.08);
+ --elev-3: rgba(168, 199, 250, 0.11);
+
+ /* Typography */
+ --font-sans: 'Plus Jakarta Sans', 'Google Sans', system-ui, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
+
+ /* Motion */
+ --ease-standard: cubic-bezier(0.2, 0, 0, 1);
+ --ease-decel: cubic-bezier(0, 0, 0, 1);
+ --ease-accel: cubic-bezier(0.3, 0, 1, 1);
+ --dur-s: 150ms;
+ --dur-m: 250ms;
+ --dur-l: 400ms;
+}
+
+/* ββ Reset ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
+
+/* ββ Base βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+html { scroll-behavior: smooth; }
+
+body {
+ font-family: var(--font-sans);
+ background: var(--md-bg);
+ color: var(--md-on-surface);
+ line-height: 1.5;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ββ Keyframes ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+@keyframes fadeDown {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes fadeUp {
+ from { opacity: 0; transform: translateY(18px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes geminiShimmer {
+ 0% { background-position: -200% center; }
+ 100% { background-position: 200% center; }
+}
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ββ Material Symbols base ββββββββββββββββββββββββββββββββββββββββ */
+.material-symbols-outlined {
+ font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
+ user-select: none;
+ flex-shrink: 0;
+}
+
+/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.admin-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 24px;
+ height: 64px;
+ background: var(--md-surface-container-low);
+ border-bottom: 1px solid var(--md-outline-variant);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ backdrop-filter: blur(20px);
+ animation: fadeDown var(--dur-l) var(--ease-decel) both;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* Gemini shimmer wordmark */
+.header-wordmark {
+ font-size: 1.1875rem;
+ font-weight: 800;
+ background: var(--gemini-gradient-wide);
+ background-size: 300% auto;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ animation: geminiShimmer 5s linear infinite;
+ letter-spacing: -0.02em;
+}
+
+.header-divider {
+ width: 1px;
+ height: 18px;
+ background: var(--md-outline-variant);
+}
+
+.header-subtitle {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--md-on-surface-variant);
+ letter-spacing: 0.01em;
+}
+
+.header-version {
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: var(--md-outline);
+ background: var(--md-surface-container-high);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: 20px;
+ padding: 1px 8px;
+ letter-spacing: 0.02em;
+ font-family: var(--font-mono);
+}
+
+/* ββ Status badge (M3 chip) βββββββββββββββββββββββββββββββββββββββ */
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 14px;
+ border-radius: var(--shape-full);
+ font-size: 0.725rem;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ transition: all var(--dur-m) var(--ease-standard);
+ border: 1px solid transparent;
+}
+.status-badge::before {
+ content: '';
+ display: block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.status-badge.connected {
+ background: rgba(129, 201, 149, 0.10);
+ color: var(--md-tertiary);
+ border-color: rgba(129, 201, 149, 0.25);
+}
+.status-badge.connected::before {
+ background: var(--md-tertiary);
+ animation: pulse-dot 2s ease-in-out infinite;
+}
+.status-badge.disconnected {
+ background: rgba(255, 180, 171, 0.10);
+ color: var(--md-error);
+ border-color: rgba(255, 180, 171, 0.25);
+}
+.status-badge.disconnected::before { background: var(--md-error); }
+.status-badge.checking {
+ background: rgba(230, 197, 90, 0.10);
+ color: var(--md-warning);
+ border-color: rgba(230, 197, 90, 0.25);
+}
+.status-badge.checking::before { background: var(--md-warning); }
+
+/* ββ Tab Navigation βββββββββββββββββββββββββββββββββββββββββββββββ */
+.tab-nav {
+ display: flex;
+ align-items: stretch;
+ padding: 0 16px;
+ background: var(--md-surface-container-low);
+ border-bottom: 1px solid var(--md-outline-variant);
+ gap: 2px;
+ animation: fadeDown var(--dur-l) var(--ease-decel) 40ms both;
+}
+
+.tab-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 18px;
+ height: 52px;
+ background: transparent;
+ border: none;
+ color: var(--md-on-surface-variant);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ letter-spacing: 0.01em;
+ border-radius: var(--shape-sm) var(--shape-sm) 0 0;
+ transition: color var(--dur-m) var(--ease-standard),
+ background var(--dur-s) var(--ease-standard);
+ overflow: hidden;
+}
+.tab-btn .material-symbols-outlined {
+ font-size: 20px;
+ font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 20;
+ transition: font-variation-settings var(--dur-m) var(--ease-standard),
+ color var(--dur-m) var(--ease-standard);
+}
+/* Active underline β Gemini gradient */
+.tab-btn::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 16px;
+ right: 16px;
+ height: 3px;
+ border-radius: 3px 3px 0 0;
+ background: var(--gemini-gradient);
+ transform: scaleX(0);
+ transform-origin: center;
+ transition: transform var(--dur-m) var(--ease-standard);
+}
+.tab-btn:hover {
+ color: var(--md-on-surface);
+ background: rgba(168, 199, 250, 0.05);
+}
+.tab-btn.active { color: var(--md-primary); }
+.tab-btn.active .material-symbols-outlined {
+ font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+}
+.tab-btn.active::after { transform: scaleX(1); }
+.tab-btn:active { background: rgba(168, 199, 250, 0.08); }
+
+/* ββ Main Content βββββββββββββββββββββββββββββββββββββββββββββββββ */
+main { padding: 24px; max-width: 1200px; margin: 0 auto; }
+
+.tab-panel { display: none; }
+.tab-panel.active {
+ display: block;
+ animation: fadeUp var(--dur-m) var(--ease-decel) both;
+}
+
+/* ββ Stats Grid βββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.stat-card {
+ background: var(--md-surface-container);
+ border-radius: var(--shape-lg);
+ padding: 20px;
+ position: relative;
+ overflow: hidden;
+ transition: background var(--dur-s) var(--ease-standard),
+ transform var(--dur-m) var(--ease-standard),
+ box-shadow var(--dur-m) var(--ease-standard);
+}
+/* M3 surface tint elevation */
+.stat-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--elev-2);
+ pointer-events: none;
+}
+.stat-card:hover {
+ background: var(--md-surface-container-high);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.25);
+}
+
+/* Staggered entrance */
+.stat-card:nth-child(1) { animation: fadeUp var(--dur-l) var(--ease-decel) 110ms both; }
+.stat-card:nth-child(2) { animation: fadeUp var(--dur-l) var(--ease-decel) 175ms both; }
+.stat-card:nth-child(3) { animation: fadeUp var(--dur-l) var(--ease-decel) 240ms both; }
+.stat-card:nth-child(4) { animation: fadeUp var(--dur-l) var(--ease-decel) 305ms both; }
+
+.stat-icon-wrap {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: var(--shape-md);
+ background: rgba(168, 199, 250, 0.10);
+ margin-bottom: 16px;
+}
+.stat-icon-wrap .material-symbols-outlined {
+ font-size: 22px;
+ font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
+ color: var(--md-primary);
+}
+
+.stat-label {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--md-on-surface-variant);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ margin-bottom: 6px;
+}
+
+.stat-value {
+ font-size: 1.875rem;
+ font-weight: 700;
+ color: var(--md-on-surface);
+ letter-spacing: -0.025em;
+ line-height: 1.1;
+}
+
+.stat-detail {
+ margin-top: 8px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ display: flex;
+ gap: 12px;
+}
+.stat-detail .success { color: var(--md-tertiary); }
+.stat-detail .error { color: var(--md-error); }
+
+/* ββ Sections βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.section {
+ background: var(--md-surface-container);
+ border-radius: var(--shape-lg);
+ padding: 24px;
+ margin-bottom: 12px;
+ position: relative;
+ overflow: hidden;
+ animation: fadeUp var(--dur-l) var(--ease-decel) 350ms both;
+}
+.section::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--elev-1);
+ pointer-events: none;
+}
+
+.section h3 {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--md-on-surface-variant);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.section h3 .material-symbols-outlined {
+ font-size: 16px;
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+ color: var(--md-primary);
+ opacity: 0.8;
+}
+
+/* ββ Tables βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+.data-table th {
+ padding: 8px 12px;
+ text-align: left;
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--md-on-surface-variant);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ border-bottom: 1px solid var(--md-outline-variant);
+}
+.data-table td {
+ padding: 12px;
+ text-align: left;
+ font-size: 0.875rem;
+ color: var(--md-on-surface);
+ border-bottom: 1px solid var(--md-outline-variant);
+ transition: background var(--dur-s) var(--ease-standard);
+}
+.data-table tr:last-child td { border-bottom: none; }
+.data-table tbody tr:hover td { background: rgba(168, 199, 250, 0.04); }
+
+/* Endpoint activity table */
+#endpoint-table th.col-num {
+ text-align: right;
+ white-space: nowrap;
+}
+#endpoint-table td.col-num {
+ text-align: right;
+ font-family: var(--font-mono);
+ font-size: 0.825rem;
+ white-space: nowrap;
+}
+#endpoint-table td.ep-count { color: var(--md-on-surface); font-weight: 600; }
+#endpoint-table td.ep-ok { color: var(--md-tertiary); }
+#endpoint-table td.ep-err { color: var(--md-error); }
+#endpoint-table td.ep-path { font-family: var(--font-mono); font-size: 0.85rem; }
+#endpoint-table td.ep-last {
+ font-size: 0.775rem;
+ color: var(--md-outline);
+ white-space: nowrap;
+}
+
+/* % traffic bar */
+.pct-bar-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ justify-content: flex-end;
+ width: 100%;
+}
+.pct-label {
+ font-family: var(--font-mono);
+ font-size: 0.775rem;
+ color: var(--md-on-surface-variant);
+ min-width: 38px;
+ text-align: right;
+}
+.pct-bar {
+ display: inline-block;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--gemini-gradient);
+ min-width: 2px;
+ max-width: 80px;
+ opacity: 0.7;
+ transition: width var(--dur-m) var(--ease-standard);
+}
+
+/* ββ API Reference ββββββββββββββββββββββββββββββββββββββββββββββββ */
+.api-ref-baseurl {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ padding: 14px 16px;
+ background: var(--md-surface-container-high);
+ border-radius: var(--shape-md);
+}
+.api-ref-baseurl .config-label {
+ font-size: 0.7rem;
+ font-weight: 700;
+ color: var(--md-on-surface-variant);
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ min-width: unset;
+}
+
+.api-url {
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ color: var(--md-primary);
+ user-select: all;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.api-ref-baseurl .api-url {
+ font-size: 0.9375rem;
+ font-weight: 500;
+}
+
+.api-ref-table td:last-child { text-align: right; font-family: var(--font-sans); }
+.api-ref-table .api-desc {
+ color: var(--md-on-surface-variant);
+ font-size: 0.8rem;
+}
+.api-ref-table code {
+ color: var(--md-on-surface);
+ word-break: break-all;
+}
+
+/* Method badges */
+.method-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 9px;
+ border-radius: var(--shape-full);
+ font-size: 0.675rem;
+ font-weight: 700;
+ font-family: var(--font-mono);
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.method-post {
+ background: rgba(129, 201, 149, 0.14);
+ color: var(--md-tertiary);
+}
+.method-get {
+ background: rgba(168, 199, 250, 0.14);
+ color: var(--md-primary);
+}
+
+/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px 22px;
+ border: none;
+ border-radius: var(--shape-full);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ letter-spacing: 0.01em;
+ position: relative;
+ overflow: hidden;
+ user-select: none;
+ transition: box-shadow var(--dur-s) var(--ease-standard),
+ background var(--dur-s) var(--ease-standard),
+ transform var(--dur-s) var(--ease-standard);
+}
+/* State layer */
+.btn::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: currentColor;
+ opacity: 0;
+ transition: opacity var(--dur-s) var(--ease-standard);
+}
+.btn:hover::after { opacity: 0.08; }
+.btn:active::after { opacity: 0.14; }
+.btn:active { transform: scale(0.98); }
+
+.btn-primary {
+ background: var(--md-primary);
+ color: var(--md-on-primary);
+}
+.btn-primary:hover {
+ box-shadow: 0 1px 3px rgba(0,0,0,0.3), 0 4px 12px rgba(66, 133, 244, 0.25);
+}
+
+.btn-tonal {
+ background: var(--md-secondary-container);
+ color: var(--md-on-secondary-container);
+}
+
+.btn-warning {
+ background: rgba(255, 180, 171, 0.10);
+ color: var(--md-error);
+ border: 1px solid rgba(255, 180, 171, 0.25);
+}
+.btn-warning:hover {
+ background: rgba(255, 180, 171, 0.16);
+}
+
+.btn-small {
+ padding: 6px 16px;
+ font-size: 0.8rem;
+}
+.btn:disabled {
+ opacity: 0.38;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* Copy chip */
+.btn-copy {
+ background: transparent;
+ color: var(--md-on-surface-variant);
+ border: 1px solid var(--md-outline-variant);
+ padding: 4px 12px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ border-radius: var(--shape-full);
+ letter-spacing: 0.02em;
+ transition: all var(--dur-s) var(--ease-standard);
+}
+.btn-copy:hover {
+ color: var(--md-primary);
+ border-color: var(--md-primary);
+ background: rgba(168, 199, 250, 0.08);
+}
+.btn-copy.copied {
+ color: var(--md-tertiary);
+ border-color: var(--md-tertiary);
+ background: rgba(129, 201, 149, 0.08);
+}
+
+/* ββ Result boxes βββββββββββββββββββββββββββββββββββββββββββββββββ */
+.result-box {
+ margin-top: 14px;
+ padding: 14px 16px;
+ border-radius: var(--shape-md);
+ font-size: 0.875rem;
+ line-height: 1.65;
+ border: 1px solid transparent;
+}
+.result-box.success {
+ background: rgba(129, 201, 149, 0.10);
+ color: var(--md-tertiary);
+ border-color: rgba(129, 201, 149, 0.22);
+}
+.result-box.error {
+ background: rgba(255, 180, 171, 0.08);
+ color: var(--md-error);
+ border-color: rgba(255, 180, 171, 0.20);
+}
+.result-box.warning {
+ background: var(--md-warning-bg);
+ color: var(--md-warning);
+ border-color: rgba(230, 197, 90, 0.22);
+}
+.result-box strong { display: block; margin-bottom: 6px; font-weight: 600; }
+.hidden { display: none !important; }
+
+.error-steps {
+ margin: 8px 0 4px;
+ padding-left: 0;
+ list-style: none;
+ font-size: 0.825rem;
+ line-height: 1.85;
+}
+.error-steps li {
+ color: var(--md-on-surface);
+ padding-left: 16px;
+ position: relative;
+}
+.error-steps li::before {
+ content: 'βΊ';
+ position: absolute;
+ left: 4px;
+ color: var(--md-error);
+ font-weight: 700;
+}
+.error-note {
+ margin-top: 10px;
+ padding: 8px 12px;
+ background: rgba(255,255,255,0.04);
+ border-radius: var(--shape-sm);
+ font-size: 0.8rem;
+ color: var(--md-on-surface-variant);
+ font-style: italic;
+}
+
+.inline-result {
+ font-size: 0.8rem;
+ font-weight: 600;
+ margin-left: 8px;
+}
+
+/* ββ Form Elements ββββββββββββββββββββββββββββββββββββββββββββββββ */
+.help-text {
+ font-size: 0.875rem;
+ color: var(--md-on-surface-variant);
+ margin-bottom: 16px;
+ line-height: 1.65;
+}
+.help-text a { color: var(--md-primary); text-decoration: none; }
+.help-text a:hover { text-decoration: underline; }
+
+.code-input {
+ width: 100%;
+ background: var(--md-surface-container-high);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: var(--shape-md);
+ color: var(--md-on-surface);
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ padding: 14px;
+ resize: vertical;
+ margin-bottom: 14px;
+ outline: none;
+ transition: border-color var(--dur-s) var(--ease-standard),
+ box-shadow var(--dur-s) var(--ease-standard);
+ line-height: 1.6;
+}
+.code-input:focus {
+ border-color: var(--md-primary);
+ box-shadow: 0 0 0 3px rgba(168, 199, 250, 0.12);
+}
+.code-input::placeholder { color: var(--md-outline); }
+
+.text-input {
+ background: var(--md-surface-container-high);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: var(--shape-md);
+ color: var(--md-on-surface);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ padding: 10px 14px;
+ min-width: 250px;
+ outline: none;
+ transition: border-color var(--dur-s) var(--ease-standard),
+ box-shadow var(--dur-s) var(--ease-standard);
+}
+.text-input:focus {
+ border-color: var(--md-primary);
+ box-shadow: 0 0 0 3px rgba(168, 199, 250, 0.12);
+}
+.text-input::placeholder { color: var(--md-outline); }
+
+.select-input {
+ background: var(--md-surface-container-high);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: var(--shape-md);
+ color: var(--md-on-surface);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ padding: 10px 36px 10px 14px;
+ cursor: pointer;
+ outline: none;
+ appearance: none;
+ -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23938F9E' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 14px center;
+ transition: border-color var(--dur-s) var(--ease-standard),
+ box-shadow var(--dur-s) var(--ease-standard);
+}
+.select-input:focus {
+ border-color: var(--md-primary);
+ box-shadow: 0 0 0 3px rgba(168, 199, 250, 0.12);
+}
+
+.config-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+.config-label {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--md-on-surface-variant);
+ min-width: 160px;
+}
+
+.form-group { margin-bottom: 14px; }
+.form-group label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--md-on-surface-variant);
+ margin-bottom: 6px;
+ letter-spacing: 0.01em;
+}
+.form-group .text-input { width: 100%; }
+
+/* ββ Collapsible ββββββββββββββββββββββββββββββββββββββββββββββββββ */
+details.collapsible summary {
+ cursor: pointer;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--md-on-surface-variant);
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-bottom: 0;
+ margin-bottom: 0;
+ user-select: none;
+ transition: color var(--dur-s) var(--ease-standard);
+}
+details.collapsible[open] summary { margin-bottom: 16px; }
+details.collapsible summary:hover { color: var(--md-on-surface); }
+details.collapsible summary .material-symbols-outlined {
+ font-size: 18px;
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+ color: var(--md-primary);
+ opacity: 0.8;
+}
+details.collapsible summary::after {
+ content: 'expand_more';
+ font-family: 'Material Symbols Outlined';
+ font-size: 20px;
+ margin-left: auto;
+ color: var(--md-outline);
+ font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
+ transition: transform var(--dur-m) var(--ease-standard);
+}
+details.collapsible[open] summary::after { transform: rotate(180deg); }
+details.collapsible summary::-webkit-details-marker { display: none; }
+
+/* ββ Log Viewer βββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.logs-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 14px;
+ flex-wrap: wrap;
+}
+/* ββ M3 Checkbox ββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.checkbox-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--md-on-surface);
+ cursor: pointer;
+ user-select: none;
+ padding: 2px 0;
+ transition: color var(--dur-s) var(--ease-standard);
+}
+.checkbox-label input[type="checkbox"] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ border: 2px solid var(--md-on-surface-variant);
+ border-radius: 2px;
+ background: transparent;
+ cursor: pointer;
+ flex-shrink: 0;
+ vertical-align: middle;
+ transition: background var(--dur-s) var(--ease-standard),
+ border-color var(--dur-s) var(--ease-standard);
+}
+.checkbox-label:hover input[type="checkbox"] {
+ border-color: var(--md-on-surface);
+}
+.checkbox-label input[type="checkbox"]:checked {
+ background: var(--md-primary);
+ border-color: var(--md-primary);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpolyline points='4 9.5 7.5 13 14 5.5' fill='none' stroke='%23062E6F' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-size: 18px 18px;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
+.log-container {
+ background: var(--md-surface-container-lowest);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: var(--shape-lg);
+ height: 520px;
+ overflow-y: auto;
+ font-family: var(--font-mono);
+ font-size: 0.775rem;
+ line-height: 1.75;
+ padding: 8px 0;
+ scrollbar-width: thin;
+ scrollbar-color: var(--md-outline-variant) transparent;
+}
+.log-container::-webkit-scrollbar { width: 6px; }
+.log-container::-webkit-scrollbar-track { background: transparent; }
+.log-container::-webkit-scrollbar-thumb {
+ background: var(--md-outline-variant);
+ border-radius: 3px;
+}
+
+.log-entry {
+ display: block;
+ padding: 1px 16px;
+ white-space: pre-wrap;
+ word-break: break-all;
+ transition: background var(--dur-s) var(--ease-standard);
+}
+.log-entry:hover { background: rgba(168, 199, 250, 0.04); }
+.log-entry .ts { color: var(--md-outline); }
+.log-entry .lvl { font-weight: 600; min-width: 60px; display: inline-block; }
+.log-entry .lvl-ERROR { color: var(--md-error); }
+.log-entry .lvl-WARNING { color: var(--md-warning); }
+.log-entry .lvl-INFO { color: var(--md-primary); }
+.log-entry .lvl-DEBUG { color: var(--md-outline); }
+.log-entry .logger-name { color: var(--md-outline); }
+.log-entry .msg { color: var(--md-on-surface); }
+
+/* ββ Empty state ββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.empty-state {
+ color: var(--md-on-surface-variant);
+ text-align: center;
+ padding: 32px 24px;
+ font-size: 0.875rem;
+}
+
+/* ββ Code βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+code {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ color: var(--md-on-surface-variant);
+}
+
+/* ββ Method badges for DELETE ββββββββββββββββββββββββββββββββββββββ */
+.method-delete { background: rgba(255, 100, 80, 0.15); color: #FF8A80; }
+
+/* ββ cURL Example Tabs βββββββββββββββββββββββββββββββββββββββββββββ */
+.curl-tabs {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.curl-tab-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.curl-tab-btn {
+ padding: 6px 14px;
+ border-radius: 20px;
+ border: 1px solid var(--md-outline-variant);
+ background: transparent;
+ color: var(--md-on-surface-variant);
+ font-family: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+ white-space: nowrap;
+}
+
+.curl-tab-btn:hover {
+ background: var(--md-surface-container-high);
+ color: var(--md-on-surface);
+}
+
+.curl-tab-btn.active {
+ background: var(--md-primary-container);
+ border-color: var(--md-primary);
+ color: var(--md-on-primary-container);
+}
+
+.curl-panel { display: none; }
+.curl-panel.active { display: block; }
+
+.curl-desc {
+ margin: 0 0 10px;
+}
+
+.curl-code-wrap {
+ position: relative;
+}
+
+.curl-code {
+ background: var(--md-surface-container-lowest);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: var(--radius-md);
+ padding: 14px 16px;
+ padding-right: 80px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.78rem;
+ line-height: 1.6;
+ color: var(--md-on-surface);
+ white-space: pre;
+ overflow-x: auto;
+ margin: 0;
+}
+
+.curl-copy-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+}
+
+.guide-steps {
+ margin: 8px 0 0;
+ padding-left: 0;
+ list-style: none;
+ counter-reset: guide-counter;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.guide-steps li {
+ counter-increment: guide-counter;
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ color: var(--md-on-surface);
+}
+.guide-steps li::before {
+ content: counter(guide-counter);
+ min-width: 24px;
+ height: 24px;
+ background: var(--md-primary-container);
+ color: var(--md-on-primary-container);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+.guide-steps code {
+ background: var(--md-surface-container-lowest);
+ border: 1px solid var(--md-outline-variant);
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.82em;
+}
+
+/* ββ Responsive βββββββββββββββββββββββββββββββββββββββββββββββββββ */
+@media (max-width: 768px) {
+ main { padding: 12px; }
+ .stats-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
+ .admin-header { padding: 0 16px; }
+ .tab-btn { padding: 0 14px; gap: 6px; }
+ .tab-label { display: none; }
+ .section { padding: 16px; }
+ .config-row { flex-direction: column; align-items: flex-start; gap: 8px; }
+ .text-input { min-width: 100%; }
+ .logs-toolbar { flex-direction: column; align-items: stretch; }
+ .api-ref-baseurl { flex-wrap: wrap; }
+ .curl-code { font-size: 0.72rem; padding-right: 70px; }
+ .curl-tab-btn { font-size: 0.75rem; padding: 5px 10px; }
+}
+@media (max-width: 480px) {
+ .stats-grid { grid-template-columns: 1fr; }
+}
diff --git a/src/static/js/app.js b/src/static/js/app.js
new file mode 100644
index 00000000..fb6e9ce4
--- /dev/null
+++ b/src/static/js/app.js
@@ -0,0 +1,45 @@
+// src/static/js/app.js - Main application controller
+
+document.addEventListener("DOMContentLoaded", () => {
+ const tabs = { dashboard: Dashboard, config: Config, logs: Logs };
+ let activeTab = "dashboard";
+
+ // Initialize all modules
+ Object.values(tabs).forEach((t) => t.init?.());
+
+ // Tab switching
+ document.querySelectorAll(".tab-btn").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const tabName = btn.dataset.tab;
+ if (tabName === activeTab) return;
+
+ // Deactivate current
+ tabs[activeTab]?.deactivate?.();
+ document.querySelector(".tab-btn.active").classList.remove("active");
+ document.getElementById(`tab-${activeTab}`).classList.remove("active");
+
+ // Activate new
+ activeTab = tabName;
+ btn.classList.add("active");
+ document.getElementById(`tab-${tabName}`).classList.add("active");
+ tabs[tabName]?.activate?.();
+ });
+ });
+
+ // Activate initial tab
+ tabs[activeTab]?.activate?.();
+
+ // Global status poll for the header badge
+ setInterval(async () => {
+ try {
+ const data = await api.get("/api/admin/status");
+ const badge = document.getElementById("connection-status");
+ badge.textContent = data.gemini_status === "connected" ? "Connected" : "Disconnected";
+ badge.className = "status-badge " + data.gemini_status;
+ } catch {
+ const badge = document.getElementById("connection-status");
+ badge.textContent = "Error";
+ badge.className = "status-badge disconnected";
+ }
+ }, 15000);
+});
diff --git a/src/static/js/config.js b/src/static/js/config.js
new file mode 100644
index 00000000..2d7032ad
--- /dev/null
+++ b/src/static/js/config.js
@@ -0,0 +1,212 @@
+// src/static/js/config.js - Configuration tab logic
+
+const Config = {
+ init() {
+ // cURL import
+ document.getElementById("btn-curl-import").addEventListener("click", () => this.handleCurlImport());
+
+ // Manual cookies
+ document.getElementById("btn-manual-cookies").addEventListener("click", () => this.handleManualCookies());
+
+ // Model save
+ document.getElementById("btn-save-model").addEventListener("click", () => this.handleModelSave());
+
+ // Proxy save
+ document.getElementById("btn-save-proxy").addEventListener("click", () => this.handleProxySave());
+
+ // Telegram
+ document.getElementById("btn-save-telegram").addEventListener("click", () => this.handleTelegramSave());
+ document.getElementById("btn-test-telegram").addEventListener("click", () => this.handleTelegramTest());
+
+ },
+
+ activate() {
+ this.refresh();
+ },
+
+ deactivate() {},
+
+ async refresh() {
+ try {
+ const data = await api.get("/api/admin/config");
+ this.updateDisplay(data);
+ } catch {
+ // Ignore on error
+ }
+ try {
+ const tg = await api.get("/api/admin/config/telegram");
+ this.updateTelegramDisplay(tg);
+ } catch {
+ // Ignore on error
+ }
+ },
+
+ updateDisplay(data) {
+ // Cookie status
+ const badge = document.getElementById("cookie-status-badge");
+ badge.textContent = data.cookies_set ? "Configured" : "Not Set";
+ badge.className = "status-badge " + (data.cookies_set ? "connected" : "disconnected");
+
+ document.getElementById("cookie-1psid-preview").textContent = data.cookie_1psid_preview || "Not set";
+ document.getElementById("cookie-1psidts-preview").textContent = data.cookie_1psidts_preview || "Not set";
+
+ // Model dropdown
+ const select = document.getElementById("model-select");
+ select.innerHTML = "";
+ (data.available_models || []).forEach(m => {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ if (m === data.model) opt.selected = true;
+ select.appendChild(opt);
+ });
+
+ // Proxy
+ document.getElementById("proxy-input").value = data.proxy || "";
+ },
+
+ async handleCurlImport() {
+ const textarea = document.getElementById("curl-input");
+ const resultDiv = document.getElementById("curl-result");
+ const text = textarea.value.trim();
+
+ if (!text) {
+ showResult(resultDiv, "error", "Please paste a cURL command or cookie string.");
+ return;
+ }
+
+ const btn = document.getElementById("btn-curl-import");
+ btn.disabled = true;
+ btn.textContent = "Importing...";
+
+ try {
+ const data = await api.post("/api/admin/config/curl-import", { curl_text: text });
+ // Always refresh β cookies are saved even if reinit fails
+ this.refresh();
+ Dashboard.refresh();
+ if (data.success) {
+ showResult(resultDiv, "success", data.message);
+ textarea.value = "";
+ } else {
+ const html = buildErrorMessage(data);
+ showResultHtml(resultDiv, "error",
+ `Cookies saved, but connection failed:
Connect from Home Assistant, n8n, or any OpenAI-compatible client using these endpoints.
+
+ Base URL
+ --
+
+
+
+
+
+
Method
+
Endpoint
+
Description
+
+
+
+
+
+
+
+
+
+
+ code
+ Usage Examples
+
+
Ready-to-use cURL commands. Click a tab to switch example, use Copy to clipboard.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ cookie
+ Import Cookies from cURL
+
+
+ Open Chrome DevTools (F12) → Network tab → navigate to
+ gemini.google.com →
+ right-click any request → Copy as cURL → paste below.
+ You can also paste just the raw Cookie header value.
+
+
+
+
+
+
+
+
+
+ verified_user
+ Cookie Status
+
+
+ Status
+ --
+
+
+ __Secure-1PSID
+ --
+
+
+ __Secure-1PSIDTS
+ --
+
+
+
+
+
+
+ edit
+ Manual Cookie Entry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ model_training
+ Model
+
+
+
+
+
+
+
+
+
+
+
+ public
+ Proxy
+
+
+
+
+
+
+
+
+
+
+ send
+ Telegram Notifications
+
+
Send alerts to Telegram when selected API errors occur.