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 Server - gpt4free Server -

- -**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. - -

- Endpoints -

- ---- - -## 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 - ``` +![1771806187019](assets/1771806187019.png) --- -## 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 [![Star History Chart](https://api.star-history.com/svg?repos=Amm1rr/WebAI-to-API&type=Date)](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://visitcount.itsvg.in/api?id=amm1rr&label=V&color=0&icon=2&pretty=true)](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:

` + html); + } + } catch (err) { + const detail = err.detail || {}; + let msg = detail.message || "Failed to import cookies"; + if (detail.errors) msg += "\n" + detail.errors.join("\n"); + if (detail.found_cookies) msg += "\nFound cookies: " + detail.found_cookies.join(", "); + showResult(resultDiv, "error", msg); + } finally { + btn.disabled = false; + btn.textContent = "Import Cookies"; + } + }, + + async handleManualCookies() { + const psid = document.getElementById("manual-1psid").value.trim(); + const psidts = document.getElementById("manual-1psidts").value.trim(); + const resultDiv = document.getElementById("manual-result"); + + if (!psid || !psidts) { + showResult(resultDiv, "error", "Both cookie values are required."); + return; + } + + try { + const data = await api.post("/api/admin/config/cookies", { + secure_1psid: psid, + secure_1psidts: psidts, + }); + this.refresh(); + Dashboard.refresh(); + if (data.success) { + showResult(resultDiv, "success", data.message); + document.getElementById("manual-1psid").value = ""; + document.getElementById("manual-1psidts").value = ""; + } else { + showResultHtml(resultDiv, "error", + `Cookies saved, but connection failed:

` + buildErrorMessage(data)); + } + } catch (err) { + showResult(resultDiv, "error", "Failed: " + (err.detail || "Unknown error")); + } + }, + + async handleModelSave() { + const model = document.getElementById("model-select").value; + const result = document.getElementById("model-result"); + try { + await api.post("/api/admin/config/model", { model }); + showInline(result, "Saved", false); + } catch { + showInline(result, "Failed", true); + } + }, + + async handleProxySave() { + const proxy = document.getElementById("proxy-input").value.trim(); + const result = document.getElementById("proxy-result"); + try { + await api.post("/api/admin/config/proxy", { http_proxy: proxy }); + showInline(result, "Saved", false); + Dashboard.refresh(); + } catch { + showInline(result, "Failed", true); + } + }, + + updateTelegramDisplay(data) { + document.getElementById("telegram-enabled").checked = !!data.enabled; + document.getElementById("telegram-chatid").value = data.chat_id || ""; + document.getElementById("telegram-cooldown").value = data.cooldown_seconds || 60; + const preview = document.getElementById("telegram-token-preview"); + preview.textContent = data.bot_token_preview || ""; + // Notify types checkboxes + const types = data.notify_types || ["auth"]; + ["auth", "503", "500"].forEach(t => { + const el = document.getElementById("notify-" + t); + if (el) el.checked = types.includes(t); + }); + }, + + async handleTelegramSave() { + const enabled = document.getElementById("telegram-enabled").checked; + const bot_token = document.getElementById("telegram-token").value.trim(); + const chat_id = document.getElementById("telegram-chatid").value.trim(); + const cooldown_seconds = parseInt(document.getElementById("telegram-cooldown").value, 10) || 60; + const notify_types = ["auth", "503", "500"].filter(t => { + const el = document.getElementById("notify-" + t); + return el && el.checked; + }); + const result = document.getElementById("telegram-result"); + try { + await api.post("/api/admin/config/telegram", { enabled, bot_token, chat_id, cooldown_seconds, notify_types }); + showInline(result, "Saved", false); + // Clear token field and refresh preview + document.getElementById("telegram-token").value = ""; + const tg = await api.get("/api/admin/config/telegram"); + this.updateTelegramDisplay(tg); + } catch { + showInline(result, "Failed", true); + } + }, + + async handleTelegramTest() { + const result = document.getElementById("telegram-result"); + const btn = document.getElementById("btn-test-telegram"); + btn.disabled = true; + btn.textContent = "Sending..."; + try { + const data = await api.post("/api/admin/config/telegram/test", {}); + showInline(result, data.message, !data.success); + } catch (err) { + const msg = (err && err.detail) ? err.detail : "Failed β€” check token and chat_id"; + showInline(result, msg, true); + } finally { + btn.disabled = false; + btn.textContent = "Send Test"; + } + }, +}; diff --git a/src/static/js/dashboard.js b/src/static/js/dashboard.js new file mode 100644 index 00000000..58d0936b --- /dev/null +++ b/src/static/js/dashboard.js @@ -0,0 +1,349 @@ +// src/static/js/dashboard.js - Dashboard tab logic + +function _relativeTime(secondsAgo) { + if (secondsAgo < 5) return "just now"; + if (secondsAgo < 60) return `${Math.floor(secondsAgo)}s ago`; + if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m ago`; + if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h ago`; + return `${Math.floor(secondsAgo / 86400)}d ago`; +} + +function copyText(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + // Fallback for HTTP (non-secure) contexts + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand("copy"); } catch (_) {} + document.body.removeChild(ta); + return Promise.resolve(); +} + +const API_ENDPOINTS = [ + { method: "GET", path: "/v1/models", desc: "List available models" }, + { method: "POST", path: "/v1/chat/completions", desc: "Chat completions β€” OpenAI format (text + vision)" }, + { method: "POST", path: "/v1/responses", desc: "Responses API β€” Home Assistant openai_conversation format (camera images)" }, + { method: "POST", path: "/v1/files", desc: "Upload image/PDF β€” returns file_id" }, + { method: "GET", path: "/v1/files/{file_id}", desc: "Get uploaded file info" }, + { method: "DELETE", path: "/v1/files/{file_id}", desc: "Delete uploaded file" }, + { method: "POST", path: "/v1beta/models/{model}", desc: "Generate content β€” Google AI format" }, + { method: "POST", path: "/gemini", desc: "Generate content (text + images in response)" }, + { method: "POST", path: "/gemini-chat", desc: "Stateful chat with session context" }, +]; + +// --------------------------------------------------------------------------- +// cURL Usage Examples +// --------------------------------------------------------------------------- +const CURL_EXAMPLES = [ + { + id: "chat", + label: "πŸ’¬ Chat", + desc: "DΓΉng cho mọi client OpenAI-compatible: Home Assistant, n8n, LangChain... Đổi model thΓ nh gemini-3.0-pro hoαΊ·c gemini-3.0-flash-thinking nαΊΏu cαΊ§n.", + curl: (base) => `curl -X POST ${base}/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gemini-3.0-flash", + "messages": [ + {"role": "user", "content": "Xin chΓ o!"} + ] + }'`, + }, + { + id: "vision", + label: "πŸ–ΌοΈ Vision", + desc: "Gα»­i αΊ£nh kΓ¨m cΓ’u hỏi. DΓΉng data:image/jpeg;base64,... cho αΊ£nh inline, hoαΊ·c URL cΓ΄ng khai β€” server tα»± tαΊ£i về.", + curl: (base) => `# DΓΉng URL cΓ΄ng khai +curl -X POST ${base}/v1/chat/completions \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gemini-3.0-flash", + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "αΊ’nh nΓ y chα»₯p gΓ¬?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/photo.jpg"}} + ] + }] + }' + +# HoαΊ·c dΓΉng base64 (Home Assistant dΓΉng format nΓ y) +# "url": "data:image/jpeg;base64,"`, + }, + { + id: "image-gen", + label: "🎨 TαΊ‘o αΊ£nh", + desc: "YΓͺu cαΊ§u Gemini sinh αΊ£nh. KαΊΏt quαΊ£ trαΊ£ về trong images[] gα»“m URL vΓ  base64.", + curl: (base) => `curl -X POST ${base}/gemini \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "gemini-3.0-flash", + "message": "VαΊ½ mα»™t bα»©c tranh hoΓ ng hΓ΄n trΓͺn biển" + }'`, + }, + { + id: "home-assistant", + label: "🏠 Home Assistant", + desc: "TΓ­ch hợp service nΓ y vΓ o Home Assistant qua extension Local OpenAI LLM (cΓ i qua HACS). Hα»— trợ chat, Δ‘iều khiển thiαΊΏt bα»‹ vΓ  phΓ’n tΓ­ch αΊ£nh camera.", + steps: [ + "CΓ i Local OpenAI LLM qua HACS: thΓͺm custom repo https://github.com/skye-harris/hass_local_openai_llm β†’ chọn Integration β†’ Install.", + "VΓ o Settings β†’ Devices & Services β†’ Add Integration, tΓ¬m Local OpenAI LLM.", + "Điền Server URL: " + location.origin + "/v1  Β·  API Key: để trα»‘ng.", + "Chọn Model tα»« dropdown β€” HA tα»± query /v1/models vΓ  hiện danh sΓ‘ch: gemini-3.0-flash, gemini-3.0-pro, gemini-3.0-flash-thinking.", + "Chat / Assist: tαΊ‘o subentry Conversation Agent β†’ chọn model vα»«a cαΊ₯u hΓ¬nh.", + "PhΓ’n tΓ­ch αΊ£nh camera: tαΊ‘o subentry AI Task Agent β†’ HA tα»± gα»­i αΊ£nh qua /v1/chat/completions, khΓ΄ng cαΊ§n cαΊ₯u hΓ¬nh thΓͺm.", + ], + }, + { + id: "list-models", + label: "πŸ“‹ Models", + desc: "LαΊ₯y danh sΓ‘ch model hiện cΓ³.", + curl: (base) => `curl ${base}/v1/models`, + }, +]; + +const Dashboard = { + intervalId: null, + + _activeCurlTab: null, + + init() { + // Render static sections + this.renderApiReference(); + this.renderCurlExamples(); + + document.getElementById("btn-reinit").addEventListener("click", async () => { + const btn = document.getElementById("btn-reinit"); + const resultEl = document.getElementById("reinit-result"); + btn.disabled = true; + btn.textContent = "Reinitializing..."; + try { + const data = await api.post("/api/admin/client/reinitialize"); + if (data.success) { + showInline(resultEl, data.message, false); + } else { + resultEl.innerHTML = buildErrorMessage(data); + resultEl.style.color = "var(--error)"; + } + } catch (err) { + showInline(resultEl, "Failed: " + (err.detail || "Unknown error"), true); + } finally { + btn.disabled = false; + btn.textContent = "Reinitialize Gemini Client"; + this.refresh(); + } + }); + + // Copy button handlers + document.querySelectorAll(".btn-copy").forEach(btn => { + btn.addEventListener("click", () => { + const targetId = btn.dataset.copyTarget; + const el = document.getElementById(targetId); + if (!el) return; + const text = el.textContent; + copyText(text).then(() => { + const orig = btn.textContent; + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1500); + }); + }); + }); + }, + + activate() { + this.refresh(); + this.intervalId = setInterval(() => this.refresh(), 10000); + }, + + deactivate() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + + async refresh() { + try { + const data = await api.get("/api/admin/status"); + this.updateCards(data); + this.updateEndpointTable(data.stats.endpoints, data.stats.endpoints_detail, data.stats.total_requests); + } catch { + document.getElementById("val-status").textContent = "Error"; + } + }, + + updateCards(data) { + const statusEl = document.getElementById("val-status"); + const statusCard = document.getElementById("card-status"); + if (data.gemini_status === "connected") { + statusEl.textContent = "Connected"; + statusEl.style.color = "var(--success)"; + statusCard.querySelector(".stat-detail")?.remove(); + } else { + statusEl.textContent = "Disconnected"; + statusEl.style.color = "var(--error)"; + // Show error hint under status card + let detailEl = statusCard.querySelector(".stat-detail"); + if (!detailEl) { + detailEl = document.createElement("div"); + detailEl.className = "stat-detail"; + statusCard.appendChild(detailEl); + } + const hints = { + auth_expired: "Cookie expired β€” get fresh cookies", + no_cookies: "No cookies β€” go to Configuration tab", + network: "Network error β€” check connection/proxy", + disabled: "Gemini disabled in config", + }; + detailEl.innerHTML = `${escapeHtml(hints[data.error_code] || data.client_error || "Check Configuration tab")}`; + } + + document.getElementById("val-model").textContent = data.current_model || "--"; + document.getElementById("val-requests").textContent = data.stats.total_requests; + document.getElementById("val-success").textContent = data.stats.success_count + " OK"; + document.getElementById("val-errors").textContent = data.stats.error_count + " ERR"; + document.getElementById("val-uptime").textContent = data.stats.uptime; + + // Update header badge + const badge = document.getElementById("connection-status"); + badge.textContent = data.gemini_status === "connected" ? "Connected" : "Disconnected"; + badge.className = "status-badge " + data.gemini_status; + + // Update version chip + const versionEl = document.getElementById("app-version"); + if (versionEl && data.version) versionEl.textContent = "v" + data.version; + }, + + updateEndpointTable(endpoints, endpointsDetail, totalRequests) { + const tbody = document.getElementById("endpoint-tbody"); + const noData = document.getElementById("no-endpoints"); + const detail = endpointsDetail || {}; + const entries = Object.entries(detail); + + if (entries.length === 0) { + tbody.innerHTML = ""; + noData.classList.remove("hidden"); + return; + } + + noData.classList.add("hidden"); + entries.sort((a, b) => b[1].count - a[1].count); + + const total = totalRequests || 1; + const now = Date.now() / 1000; + + tbody.innerHTML = entries.map(([path, d]) => { + const pct = total > 0 ? ((d.count / total) * 100).toFixed(1) : "0.0"; + const lastSeen = d.last_seen ? _relativeTime(now - d.last_seen) : "β€”"; + const errClass = d.error > 0 ? ' class="ep-err"' : ''; + return ` + ${escapeHtml(path)} + ${d.count} + ${d.success} + ${d.error} + + + + ${pct}% + + + ${escapeHtml(lastSeen)} + `; + }).join(""); + }, + + getBaseUrl() { + return window.location.origin; + }, + + renderApiReference() { + const baseUrl = this.getBaseUrl(); + document.getElementById("api-base-url").textContent = baseUrl + "/v1"; + + const tbody = document.getElementById("api-ref-tbody"); + tbody.innerHTML = API_ENDPOINTS.map(ep => { + const fullUrl = baseUrl + ep.path; + const methodLower = ep.method.toLowerCase(); + return ` + ${ep.method} + ${escapeHtml(fullUrl)} + ${escapeHtml(ep.desc)} + + `; + }).join(""); + + tbody.querySelectorAll(".btn-copy").forEach(btn => { + btn.addEventListener("click", () => { + const text = btn.dataset.copyValue; + copyText(text).then(() => { + const orig = btn.textContent; + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1500); + }); + }); + }); + }, + + renderCurlExamples() { + const baseUrl = this.getBaseUrl(); + const container = document.getElementById("curl-tabs"); + if (!container) return; + + // Tab buttons row + const tabsHtml = CURL_EXAMPLES.map((ex, i) => + `` + ).join(""); + + // Panels + const panelsHtml = CURL_EXAMPLES.map((ex, i) => { + let bodyHtml; + if (ex.steps) { + const items = ex.steps.map(s => `
  • ${s}
  • `).join(""); + bodyHtml = `
      ${items}
    `; + } else { + const curlText = ex.curl(baseUrl); + bodyHtml = `
    +
    ${escapeHtml(curlText)}
    + +
    `; + } + return `
    +

    ${ex.desc}

    + ${bodyHtml} +
    `; + }).join(""); + + container.innerHTML = `
    ${tabsHtml}
    ${panelsHtml}
    `; + + // Tab switching + container.querySelectorAll(".curl-tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + const tabId = btn.dataset.tab; + container.querySelectorAll(".curl-tab-btn").forEach(b => b.classList.remove("active")); + container.querySelectorAll(".curl-panel").forEach(p => p.classList.remove("active")); + btn.classList.add("active"); + container.querySelector(`#curl-panel-${tabId}`)?.classList.add("active"); + }); + }); + + // Copy buttons + container.querySelectorAll(".curl-copy-btn").forEach(btn => { + btn.addEventListener("click", () => { + const el = document.getElementById(btn.dataset.copyId); + if (!el) return; + copyText(el.textContent).then(() => { + const orig = btn.textContent; + btn.textContent = "Copied!"; + btn.classList.add("copied"); + setTimeout(() => { btn.textContent = orig; btn.classList.remove("copied"); }, 1500); + }); + }); + }); + }, +}; diff --git a/src/static/js/logs.js b/src/static/js/logs.js new file mode 100644 index 00000000..b3ae983a --- /dev/null +++ b/src/static/js/logs.js @@ -0,0 +1,109 @@ +// src/static/js/logs.js - Real-time log viewer with SSE + +const Logs = { + eventSource: null, + entries: [], + lastId: 0, + autoScroll: true, + + init() { + document.getElementById("log-autoscroll").addEventListener("change", (e) => { + this.autoScroll = e.target.checked; + }); + + document.getElementById("btn-clear-logs").addEventListener("click", () => { + this.entries = []; + this.renderAll(); + }); + + document.getElementById("log-level-filter").addEventListener("change", () => this.renderAll()); + document.getElementById("log-search").addEventListener("input", () => this.renderAll()); + }, + + async activate() { + // Load recent logs first + try { + const data = await api.get("/api/admin/logs/recent?count=100"); + this.entries = data.logs || []; + if (this.entries.length > 0) { + this.lastId = this.entries[this.entries.length - 1].id; + } + } catch { + this.entries = []; + } + this.renderAll(); + this.connectSSE(); + }, + + deactivate() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + }, + + connectSSE() { + if (this.eventSource) this.eventSource.close(); + + const url = `/api/admin/logs/stream?last_id=${this.lastId}`; + this.eventSource = new EventSource(url); + + this.eventSource.addEventListener("log", (event) => { + const entry = JSON.parse(event.data); + this.lastId = parseInt(event.lastEventId) || entry.id; + this.entries.push(entry); + + // Cap at 1000 entries client-side + if (this.entries.length > 1000) { + this.entries = this.entries.slice(-500); + } + + if (this.matchesFilter(entry)) { + this.appendEntry(entry); + } + }); + + this.eventSource.onerror = () => { + // EventSource auto-reconnects + }; + }, + + matchesFilter(entry) { + const levelFilter = document.getElementById("log-level-filter").value; + const searchText = document.getElementById("log-search").value.toLowerCase(); + + if (levelFilter !== "ALL" && entry.level !== levelFilter) return false; + if (searchText && !entry.message.toLowerCase().includes(searchText) && + !entry.logger.toLowerCase().includes(searchText)) return false; + + return true; + }, + + renderAll() { + const container = document.getElementById("log-container"); + const filtered = this.entries.filter(e => this.matchesFilter(e)); + + if (filtered.length === 0) { + container.innerHTML = '

    Waiting for logs...

    '; + return; + } + + container.innerHTML = filtered.map(e => this.formatEntry(e)).join(""); + if (this.autoScroll) container.scrollTop = container.scrollHeight; + }, + + appendEntry(entry) { + const container = document.getElementById("log-container"); + // Remove empty state if present + const emptyState = container.querySelector(".empty-state"); + if (emptyState) emptyState.remove(); + + container.insertAdjacentHTML("beforeend", this.formatEntry(entry)); + if (this.autoScroll) container.scrollTop = container.scrollHeight; + }, + + formatEntry(entry) { + const ts = entry.timestamp.split("T")[1] || entry.timestamp; + return `
    ${escapeHtml(ts)} ${entry.level.padEnd(7)} [${escapeHtml(entry.logger)}] ${escapeHtml(entry.message)}
    `; + }, +}; diff --git a/src/static/js/utils.js b/src/static/js/utils.js new file mode 100644 index 00000000..5bbfe825 --- /dev/null +++ b/src/static/js/utils.js @@ -0,0 +1,103 @@ +// src/static/js/utils.js - Shared API wrapper and helpers + +const api = { + async get(url) { + const res = await fetch(url); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw err; + } + return res.json(); + }, + + async post(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw err; + } + return res.json(); + }, +}; + +function showResult(el, type, message) { + el.textContent = message; + el.className = `result-box ${type}`; + el.classList.remove("hidden"); +} + +function showInline(el, message, isError) { + el.textContent = message; + el.style.color = isError ? "var(--error)" : "var(--success)"; + setTimeout(() => { el.textContent = ""; }, 3000); +} + +function escapeHtml(text) { + const d = document.createElement("div"); + d.textContent = text; + return d.innerHTML; +} + +/** + * Build a user-friendly error message with actionable guidance based on error_code. + */ +function buildErrorMessage(data) { + const code = data.error_code; + const detail = data.error_detail || ""; + const hints = { + auth_expired: { + title: "Cookie expired or invalid", + steps: [ + "1. Open Chrome \u2192 go to gemini.google.com \u2192 make sure you are logged in", + "2. Open DevTools (F12) \u2192 Network tab", + "3. Reload the page, right-click any request \u2192 Copy as cURL", + "4. Paste it in the Configuration tab \u2192 Import Cookies", + ], + note: "Cookies (especially __Secure-1PSIDTS) expire frequently. You may need to repeat this every few hours.", + }, + no_cookies: { + title: "No cookies configured", + steps: [ + "1. Go to the Configuration tab", + "2. Paste a cURL from gemini.google.com DevTools \u2192 Import Cookies", + ], + note: null, + }, + network: { + title: "Cannot reach gemini.google.com", + steps: [ + "Check your internet connection or proxy settings.", + "If behind a firewall, configure the proxy in the Configuration tab.", + ], + note: detail || null, + }, + disabled: { + title: "Gemini is disabled in configuration", + steps: ["Enable Gemini in config.conf under [EnabledAI] section."], + note: null, + }, + }; + const info = hints[code] || { + title: "Connection failed", + steps: [detail || "Check the Logs tab for details."], + note: null, + }; + let html = `${escapeHtml(info.title)}`; + html += `
      `; + info.steps.forEach(s => { html += `
    • ${escapeHtml(s)}
    • `; }); + html += `
    `; + if (info.note) { + html += `
    ${escapeHtml(info.note)}
    `; + } + return html; +} + +function showResultHtml(el, type, html) { + el.innerHTML = html; + el.className = `result-box ${type}`; + el.classList.remove("hidden"); +} diff --git a/src/templates/admin.html b/src/templates/admin.html new file mode 100644 index 00000000..8a980be1 --- /dev/null +++ b/src/templates/admin.html @@ -0,0 +1,306 @@ + + + + + + WebAI-to-API Admin + + + + + + + + + + +
    +
    + WebAI-to-API +
    + Admin + +
    + Checking... +
    + + + +
    + +
    +
    +
    +
    + wifi +
    +
    Gemini Status
    +
    --
    +
    +
    +
    + smart_toy +
    +
    Current Model
    +
    --
    +
    +
    +
    + bar_chart +
    +
    Total Requests
    +
    --
    +
    + 0 OK + 0 ERR +
    +
    +
    +
    + schedule +
    +
    Uptime
    +
    --
    +
    +
    + +
    +

    + show_chart + Endpoint Activity +

    + + + + + + + + + + + + +
    EndpointRequestsOKERR% TrafficLast Seen
    +

    No requests yet

    +
    + +
    +

    + api + API Reference +

    +

    Connect from Home Assistant, n8n, or any OpenAI-compatible client using these endpoints.

    +
    + Base URL + -- + +
    + + + + + + + + + + +
    MethodEndpointDescription
    +
    + + +
    +

    + 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.

    +
    + +
    +
    + + + +
    +
    + + +
    +
    + +
    + + + +
    +
    +
    + Cooldown (seconds) + +
    +
    + + + +
    +
    +
    + + +
    +
    + + + + +
    +
    +

    Waiting for logs...

    +
    +
    +
    + + + + + + + +