Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9858e06b73 | |||
| ccc5126ae0 | |||
| 4d29bb3ca4 | |||
| 64b20754a1 | |||
| 6fb9bade08 | |||
| 70475865cc | |||
| 98b4b46b11 | |||
| 834c2c5bfe | |||
| b7ac24f2ff | |||
| 69bf5edefe | |||
| 4c0d161f10 | |||
| c24a44618f | |||
| c183a13db2 | |||
| c682d2b320 | |||
| aa61ec54b9 | |||
| b79229a5b0 | |||
| 7d1a89475a | |||
| 69b8697389 | |||
| 0367dac3cd | |||
| 828fc7d16a | |||
| fcadec8bf6 | |||
| 2674a49574 | |||
| 4c55168d52 | |||
| 863ebe8e20 | |||
| b6b866c9fc | |||
| f709528def | |||
| d23273590f | |||
| 53bbea42aa | |||
| d806416f93 | |||
| c6419f8931 | |||
| 790a7059ce | |||
| 171410cf26 | |||
| 504753eef9 | |||
| 15a03dba91 | |||
| c9c75b6be3 | |||
| 0e80e8b9df | |||
| 6e72d5f95a | |||
| 35b0e74a41 | |||
| 2542fa2342 | |||
| e36bf35b2a | |||
| 19aa2b17d7 | |||
| 43e0de24d6 | |||
| 2a81ea8f76 | |||
| 810d32dcc3 | |||
| 6621a99149 | |||
| 801a90746b | |||
| 202baa17e2 | |||
| f1ab1accaf | |||
| bb4619aefe | |||
| 82f16bfd6a | |||
| 7912c83ca3 | |||
| bc19f52052 | |||
| 0122e018ac | |||
| 7474055c97 | |||
| 0478ee7568 | |||
| 46a4b874bd | |||
| 9777eaf9e1 | |||
| bb2103e81e | |||
| 2a1f125fa5 | |||
| 8fd6fa9ece | |||
| 3875ed4e4f | |||
| c071fd12e1 | |||
| 6427b71043 | |||
| b132efe6a0 | |||
| d7639d9d0a | |||
| a591cfa343 | |||
| ca37c4a780 | |||
| 7527ef3adf | |||
| a172a71a70 | |||
| 600791ea5d | |||
| 0212058677 | |||
| 684460903f | |||
| 0a0df266c2 | |||
| 1e4942e0b8 | |||
| 2988482791 | |||
| 56ad5f7046 | |||
| b361377088 | |||
| ce461b6ff9 | |||
| bddd741fcc | |||
| 2db7e6f2e5 | |||
| b23fd19c4b | |||
| 4e3842495f | |||
| 434c394651 | |||
| 20d44bb3b3 | |||
| d8d01a5fff | |||
| 3318839551 | |||
| 4fe5b8daea | |||
| 53f76f58ea | |||
| b918522816 | |||
| eb8cd19e46 | |||
| 09dd983c4e | |||
| 35a2e10bc8 | |||
| 530a90f237 | |||
| bde4a994b7 | |||
| 6a62db2e85 | |||
| 47db945393 | |||
| 6293d9df0a | |||
| 32285891fc | |||
| 7aef6484c3 | |||
| 9dcb0f91d0 | |||
| cd94f98c63 | |||
| 01969b2d24 | |||
| add9946bc2 | |||
| 9bccac82f3 | |||
| 489066940d | |||
| 467e752629 | |||
| f5203e0466 | |||
| 3ba93c55f4 | |||
| a693874662 | |||
| 846477075e | |||
| 1e753bfaab | |||
| 20576cabf3 | |||
| 02dd3df1f9 | |||
| 33f19e704c | |||
| 8eee58de60 | |||
| 98c47d5507 | |||
| 9a149cdaa6 | |||
| 00c9b44c0e | |||
| 73e952c617 | |||
| 9f51dc3cdb | |||
| 1e6bc536b4 | |||
| 869de1c250 | |||
| 2cd77c68c1 | |||
| ad5b889bf1 | |||
| 75e6b09464 | |||
| 47238f8567 | |||
| 5e0e2994c2 | |||
| 9629bfcef4 | |||
| b4097730ef | |||
| abb06b78e2 | |||
| 92171cbfb6 | |||
| 8e884b5e76 | |||
| ed973357e8 | |||
| 4ac0754ea7 | |||
| db977270a3 | |||
| bac773ae4b | |||
| 564a9b68a5 | |||
| 7742673cc0 | |||
| c157c37cde | |||
| 3b8fa3e7a0 | |||
| 438399646f | |||
| 9ed4ca126a | |||
| f3ae76ce68 | |||
| 7ee3bdef84 | |||
| 500c44feb1 | |||
| 896501deb1 | |||
| c95800e65d | |||
| 90372a6a6d |
+67
-6
@@ -10,6 +10,12 @@ POSTGRES_DB=raggr
|
||||
# JWT Configuration
|
||||
JWT_SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Metrics Configuration
|
||||
# Bearer token for the /metrics endpoint (Prometheus scrape + app token gate).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# Leave empty to keep /metrics returning 403 (fail-closed).
|
||||
METRICS_TOKEN=
|
||||
|
||||
# Paperless Configuration
|
||||
PAPERLESS_TOKEN=your-paperless-token
|
||||
BASE_URL=192.168.1.5:8000
|
||||
@@ -18,11 +24,13 @@ BASE_URL=192.168.1.5:8000
|
||||
# If set, uses llama-server as the primary LLM backend with OpenAI as fallback
|
||||
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
|
||||
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
|
||||
TTFT_TIMEOUT_SECONDS=30
|
||||
|
||||
# ChromaDB Configuration
|
||||
# For Docker: This is automatically set to /app/data/chromadb
|
||||
# For local development: Set to a local directory path
|
||||
CHROMADB_PATH=./data/chromadb
|
||||
# Embedding Server Configuration
|
||||
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
|
||||
# Falls back to OpenAI embeddings if not set
|
||||
EMBEDDING_SERVER_URL=http://192.168.1.7:8086/v1
|
||||
EMBEDDING_MODEL_NAME=all-minilm
|
||||
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
@@ -92,9 +100,54 @@ EMAIL_HMAC_SECRET=
|
||||
# Set to false to disable Mailgun signature validation in development
|
||||
MAILGUN_SIGNATURE_VALIDATION=true
|
||||
|
||||
# SendBlue Configuration (iMessage)
|
||||
SENDBLUE_API_KEY=your-sendblue-api-key
|
||||
SENDBLUE_API_SECRET=your-sendblue-api-secret
|
||||
SENDBLUE_FROM_NUMBER=+1XXXXXXXXXX
|
||||
SENDBLUE_WEBHOOK_SECRET=your-sendblue-webhook-secret
|
||||
# Set to false to disable SendBlue signature validation in development
|
||||
SENDBLUE_SIGNATURE_VALIDATION=true
|
||||
# Comma-separated list of iMessage numbers allowed to use the service (E.164 format)
|
||||
# Use * to allow any number
|
||||
ALLOWED_IMESSAGE_NUMBERS=
|
||||
# Rate limiting: max messages per window (default: 10 messages per 60 seconds)
|
||||
# IMESSAGE_RATE_LIMIT_MAX=10
|
||||
# IMESSAGE_RATE_LIMIT_WINDOW=60
|
||||
|
||||
# Google Calendar + Gmail Configuration (Python SDK)
|
||||
# Run scripts/google_auth.py to generate token.json from your OAuth2 credentials:
|
||||
# python scripts/google_auth.py --credentials credentials.json --token token.json
|
||||
# Then mount token.json into the container (see docker-compose.yml).
|
||||
# If GOOGLE_TOKEN_FILE points to a valid token, both Calendar and Gmail are enabled.
|
||||
GOOGLE_TOKEN_FILE=/app/config/google-token.json
|
||||
|
||||
# Cronicle Integration
|
||||
# Shared secret for Cronicle to authenticate cron job trigger requests.
|
||||
# Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=
|
||||
# Cronicle server URL and API key (from Cronicle Admin → API Keys)
|
||||
# Used to auto-register scheduled jobs on startup.
|
||||
CRONICLE_URL=http://cronicle:3012
|
||||
CRONICLE_API_KEY=
|
||||
|
||||
# SimbaKit / PetKit Configuration
|
||||
# PetKit account credentials (used by SimbaKit to poll device data)
|
||||
PETKIT_USERNAME=your-petkit-email
|
||||
PETKIT_PASSWORD=your-petkit-password
|
||||
PETKIT_REGION=US
|
||||
PETKIT_TIMEZONE=America/New_York
|
||||
# Internal URL for SimbaKit service (Docker network)
|
||||
SIMBAKIT_URL=http://simbakit:8585
|
||||
|
||||
# Homebox inventory (admin-only agent tools via homebox-mcp, needs Homebox >= 0.26)
|
||||
# API key from Homebox Profile -> API Keys (starts with hb_). Leave empty to disable.
|
||||
HOMEBOX_URL=https://homebox.example.com
|
||||
HOMEBOX_TOKEN=
|
||||
|
||||
# Obsidian Configuration (headless sync)
|
||||
# Auth token from Obsidian account (Settings → Account → API token)
|
||||
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
||||
# Obsidian account credentials (used for `ob login` on container startup)
|
||||
OBSIDIAN_EMAIL=your-obsidian-email
|
||||
OBSIDIAN_PASSWORD=your-obsidian-password
|
||||
# Vault ID to sync (found in Obsidian sync settings)
|
||||
OBSIDIAN_VAULT_ID=your-vault-id
|
||||
# End-to-end encryption password (if vault uses E2E encryption)
|
||||
@@ -105,3 +158,11 @@ OBSIDIAN_DEVICE_NAME=simbarag
|
||||
OBSIDIAN_CONTINUOUS_SYNC=false
|
||||
# Local path to Obsidian vault (where files are synced)
|
||||
OBSIDIAN_VAULT_PATH=/app/data/obsidian
|
||||
|
||||
# LangGraph checkpoint store (SQLite). Must be on a persistent volume.
|
||||
# Both blue and green containers share this volume via docker-compose.
|
||||
CHECKPOINT_DB_PATH=/app/checkpoints/checkpoints.db
|
||||
|
||||
# Beli restaurant recommendations
|
||||
# Beli account credentials for each profile are stored encrypted in the database.
|
||||
# No additional env vars needed — auth is done via the Beli API at login time.
|
||||
|
||||
+2
-3
@@ -11,11 +11,10 @@ wheels/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
credentials.json
|
||||
token.json
|
||||
|
||||
# Database files
|
||||
chromadb/
|
||||
chromadb_openai/
|
||||
chroma_db/
|
||||
database/
|
||||
*.db
|
||||
|
||||
|
||||
@@ -4,33 +4,39 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
SimbaRAG is a RAG (Retrieval-Augmented Generation) conversational AI system for querying information about Simba (a cat). It ingests documents from Paperless-NGX, stores embeddings in ChromaDB, and uses LLMs (Ollama or OpenAI) to answer questions.
|
||||
SimbaRAG is a RAG (Retrieval-Augmented Generation) conversational AI system for querying information about Simba (a cat) and the household. It ingests documents from Paperless-NGX, Obsidian, and Immich, stores embeddings in PostgreSQL via pgvector (Chroma client), uses LLMs (Ollama or OpenAI) to answer questions with a tool-using LangChain agent, and exposes chat over web, iMessage, and WhatsApp.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start environment
|
||||
docker compose up --build
|
||||
# Start environment (raggr runs as raggr-blue/raggr-green behind compose profiles)
|
||||
docker compose --profile blue up --build
|
||||
|
||||
# View logs
|
||||
docker compose logs -f raggr
|
||||
# View logs (Makefile resolves the active color)
|
||||
make logs
|
||||
|
||||
# Reindex vector store (all sources / Obsidian only)
|
||||
make reindex
|
||||
make reindex-obsidian
|
||||
```
|
||||
|
||||
### Database Migrations (Aerich/Tortoise ORM)
|
||||
|
||||
```bash
|
||||
# Generate migration (must run in Docker with DB access)
|
||||
docker compose exec raggr aerich migrate --name describe_change
|
||||
make migrate-new
|
||||
|
||||
# Apply migrations (auto-runs on startup, manual if needed)
|
||||
docker compose exec raggr aerich upgrade
|
||||
make migrate
|
||||
|
||||
# View migration history
|
||||
docker compose exec raggr aerich history
|
||||
make migrate-history
|
||||
```
|
||||
|
||||
Migrations must be backward-compatible: blue-green deploys run `aerich upgrade` in the new container while the old code still serves.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
@@ -42,9 +48,10 @@ yarn dev # Dev server (rarely needed, backend serves frontend)
|
||||
|
||||
### Production
|
||||
|
||||
Zero-downtime blue-green deploy: `raggr-blue` (port 8080) and `raggr-green` (port 8081) alternate behind Caddy health checks. See `docs/deployment.md`.
|
||||
|
||||
```bash
|
||||
docker compose build raggr
|
||||
docker compose up -d
|
||||
make redeploy # git pull + scripts/deploy.sh (build new color, wait healthy, stop old)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -53,10 +60,11 @@ docker compose up -d
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ raggr (port 8080) │ postgres (port 5432) │
|
||||
│ ├── Quart backend │ PostgreSQL 16 │
|
||||
│ raggr-blue/green (8080/8081) │ postgres (port 5432) │
|
||||
│ ├── Quart backend │ PostgreSQL 16 + pgvector│
|
||||
│ ├── React frontend (served) │ │
|
||||
│ └── ChromaDB (volume) │ │
|
||||
│ └── Twilio webhooks (iMessage/ │ │
|
||||
│ WhatsApp) │ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -64,33 +72,50 @@ docker compose up -d
|
||||
- `app.py` - Quart application entry, serves API and static frontend
|
||||
- `main.py` - RAG logic, document indexing, LLM interaction, LangChain agent
|
||||
- `llm.py` - LLM client with Ollama primary, OpenAI fallback
|
||||
- `aerich_config.py` - Database migration configuration
|
||||
- `blueprints/` - API routes organized as Quart blueprints
|
||||
- `users/` - OIDC auth, JWT tokens, RBAC with LDAP groups
|
||||
- `conversation/` - Chat conversations and message history
|
||||
- `rag/` - Document indexing endpoints (admin-only)
|
||||
- `conversation/` - Chat conversations, message history, streamed answers
|
||||
- `rag/` - Document indexing endpoints (admin-only); logic in `rag/logic.py`
|
||||
- `email/` - Email integration (Mailgun webhooks, Fernet encryption, IMAP, parsing)
|
||||
- `imessage/` - Twilio webhook receiver for iMessage (HMAC-validated, number allowlist)
|
||||
- `whatsapp/` - Twilio webhook receiver for WhatsApp (TWIML replies, SendBlue send support)
|
||||
- `scheduled_messages/` - Scheduled message delivery with background scheduler
|
||||
- `config/` - Configuration modules
|
||||
- `db.py` - Database setup
|
||||
- `oidc_config.py` - OIDC authentication configuration
|
||||
- `utils/` - Reusable utilities
|
||||
- `utils/` - Reusable utilities and household integrations
|
||||
- `chunker.py` - Document chunking for embeddings
|
||||
- `cleaner.py` - PDF cleaning and summarization
|
||||
- `image_process.py` - Image description with LLM
|
||||
- `request.py` - Paperless-NGX API client
|
||||
- `obsidian_service.py`, `mealie_service.py`, `ynab_service.py`, `s3_client.py`, `simbakit_service.py` - integrations with Obsidian notes, Mealie recipes, YNAB budgets, S3, and SimbaKit
|
||||
- `homebox_mcp.py` - Homebox inventory tools via the `homebox-mcp` stdio MCP server; admin-only (compiled into `app.admin_agent`, picked per user by `get_agent_for`)
|
||||
- `scripts/` - Administrative and utility scripts
|
||||
- `add_user.py` - Create users manually
|
||||
- `user_message_stats.py` - User message statistics
|
||||
- `manage_vectorstore.py` - Vector store management CLI
|
||||
- `inspect_vector_store.py` - Inspect ChromaDB contents
|
||||
- `query.py` - Query generation utilities
|
||||
- `manage_vectorstore.py` / `inspect_vector_store.py` - Vector store CLI
|
||||
- `index_immich.py` - Index Immich photos into the vector store
|
||||
- `link_imessage.py` - Link iMessage contacts to users
|
||||
- `deploy.sh` - Blue-green deployment script
|
||||
- `migrations/` - Database migration files
|
||||
- `tests/` - pytest test suite (`tests/unit/` covers email/crypto, YNAB, Obsidian, SimbaKit, rate limiting, user model)
|
||||
|
||||
**simbakit/** (standalone subproject):
|
||||
- Self-hosted PetKit device poller + web dashboard for Simba's smart devices (feeder, litter box, fountain, purifier)
|
||||
- Own `pyproject.toml`, `uv.lock`, and Dockerfile; Python 3.14 + uv
|
||||
- Polls PetKit cloud API every 2 min into SQLite; Chart.js dashboard; docs in `simbakit/docs/`
|
||||
- Queried from the main app via `utils/simbakit_service.py`
|
||||
|
||||
**Frontend** (`raggr-frontend/`):
|
||||
- React 19 with Rsbuild bundler
|
||||
- Tailwind CSS for styling
|
||||
- Built to `dist/`, served by backend at `/`
|
||||
- Key components: `ChatScreen`, `ConversationsPage`, `LoginScreen`, `AdminPanel`, `ScheduledMessagesPanel`, `ToolBubble` (agent tool-call display)
|
||||
|
||||
**Auth Flow**: LLDAP → Authelia (OIDC) → Backend JWT → Frontend localStorage
|
||||
|
||||
**Data Sources**: Paperless-NGX documents, Obsidian vault, Immich photos (indexed via `scripts/index_immich.py` / `make reindex`)
|
||||
|
||||
## Testing
|
||||
|
||||
Always run `make test` before pushing code to ensure all tests pass.
|
||||
@@ -100,12 +125,23 @@ make test # Run tests
|
||||
make test-cov # Run tests with coverage
|
||||
```
|
||||
|
||||
Note: `make test` may fail locally (no pytest on PATH); use `.venv/bin/pytest tests/ -q` instead.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- The host runs **Gitea** (`git.torrtle.co`), not GitHub, despite the remote name. Use the **`tea` CLI** for PRs (Gitea flavor: `tea pr create`, `tea pr list`, etc. — no `tea create`).
|
||||
- Typical flow: commit locally, `git push -u origin <branch>`, then `tea pr create -t "title" -d "body"`.
|
||||
- "kermit" is the user's slang for **commit** (e.g. "kermit and push" = commit and push). There is no kermit tool.
|
||||
- Keep one logical change per branch/PR; don't ride along on stale feature branches.
|
||||
- Don't commit `.DS_Store`, `chroma_db/`, `chromadb/`, `.planning/` (local artifacts).
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- All endpoints are async (`async def`)
|
||||
- Use `@jwt_refresh_token_required` for authenticated endpoints
|
||||
- Use `@admin_required` for admin-only endpoints (checks `lldap_admin` group)
|
||||
- Tortoise ORM models in `blueprints/*/models.py`
|
||||
- External integrations are service classes in `utils/*_service.py`
|
||||
- Frontend API services in `raggr-frontend/src/api/`
|
||||
|
||||
## Environment Variables
|
||||
@@ -113,6 +149,15 @@ make test-cov # Run tests with coverage
|
||||
See `.env.example`. Key ones:
|
||||
- `DATABASE_URL` - PostgreSQL connection
|
||||
- `OIDC_*` - Authelia OIDC configuration
|
||||
- `OLLAMA_URL` - Local LLM server
|
||||
- `OPENAI_API_KEY` - Fallback LLM
|
||||
- `LLAMA_SERVER_URL` / `LLAMA_MODEL_NAME` - Local Llama/Ollama LLM server
|
||||
- `EMBEDDING_SERVER_URL` / `EMBEDDING_MODEL_NAME` - Embedding model
|
||||
- `OPENAI_API_KEY` - Fallback LLM; `TAVILY_API_KEY` - web search tool
|
||||
- `PAPERLESS_TOKEN` / `BASE_URL` - Document source
|
||||
- `TWILIO_*`, `ALLOWED_IMESSAGE_NUMBERS`, `ALLOWED_WHATSAPP_NUMBERS` - iMessage/WhatsApp webhooks
|
||||
- `MAILGUN_*`, `EMAIL_HMAC_SECRET`, `FERNET_KEY` - email integration
|
||||
- `SENDBLUE_*` - SMS sending for scheduled messages
|
||||
- `YNAB_*`, `MEALIE_*`, `OBSIDIAN_*`, `IMMICH_*`, `SIMBAKIT_URL`, `HOMEBOX_URL`/`HOMEBOX_TOKEN` - household integrations
|
||||
|
||||
## Documentation
|
||||
|
||||
Project docs live in `docs/` (mkdocs): `deployment.md`, `development.md`, `authentication.md`, `MIGRATIONS.md`, `VECTORSTORE.md`, `TASKS.md`, `ynab_integration/`.
|
||||
|
||||
+6
-4
@@ -8,13 +8,16 @@ RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g yarn obsidian-headless \
|
||||
&& npm install -g yarn obsidian-headless @googleworkspace/cli \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Add uv to PATH
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Homebox MCP server (stdio), pinned so the tool surface can't drift
|
||||
RUN uv tool install homebox-mcp==1.1.0
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml ./
|
||||
|
||||
@@ -37,15 +40,14 @@ WORKDIR /app/raggr-frontend
|
||||
RUN yarn install && yarn build
|
||||
WORKDIR /app
|
||||
|
||||
# Create ChromaDB and database directories
|
||||
RUN mkdir -p /app/chromadb /app/database
|
||||
# Create database directory
|
||||
RUN mkdir -p /app/database
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV CHROMADB_PATH=/app/chromadb
|
||||
|
||||
# Run the startup script
|
||||
CMD ["./startup.sh"]
|
||||
|
||||
+2
-3
@@ -34,16 +34,15 @@ COPY . .
|
||||
WORKDIR /app/raggr-frontend
|
||||
RUN yarn build
|
||||
|
||||
# Create ChromaDB and database directories
|
||||
# Create database directory
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/chromadb /app/database
|
||||
RUN mkdir -p /app/database
|
||||
|
||||
# Make startup script executable
|
||||
RUN chmod +x /app/startup-dev.sh
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV CHROMADB_PATH=/app/chromadb
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose port
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
.PHONY: deploy redeploy build up down restart logs migrate migrate-new frontend test
|
||||
.PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend test test-cov reindex reindex-obsidian
|
||||
|
||||
# Blue-green: detect the running color (default blue when nothing is running)
|
||||
ACTIVE_COLOR := $(shell docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 | cut -d- -f2)
|
||||
ifeq ($(ACTIVE_COLOR),)
|
||||
ACTIVE_COLOR := blue
|
||||
endif
|
||||
RAGGR := raggr-$(ACTIVE_COLOR)
|
||||
# Both profiles enabled for commands that only touch existing containers
|
||||
# (never use this with `up`, which would start both colors)
|
||||
COMPOSE := docker compose --profile blue --profile green
|
||||
|
||||
# Build and deploy
|
||||
deploy: build up
|
||||
|
||||
# Zero-downtime blue-green swap (see scripts/deploy.sh)
|
||||
redeploy:
|
||||
git pull && $(MAKE) down && $(MAKE) up
|
||||
git pull && ./scripts/deploy.sh
|
||||
|
||||
build:
|
||||
docker compose build raggr
|
||||
docker compose --profile $(ACTIVE_COLOR) build $(RAGGR)
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
docker compose --profile $(ACTIVE_COLOR) up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
$(COMPOSE) down
|
||||
|
||||
restart:
|
||||
docker compose restart raggr
|
||||
$(COMPOSE) restart $(RAGGR)
|
||||
|
||||
logs:
|
||||
docker compose logs -f raggr
|
||||
$(COMPOSE) logs -f $(RAGGR)
|
||||
|
||||
# Vector store reindexing
|
||||
reindex:
|
||||
$(COMPOSE) exec $(RAGGR) python -c "\
|
||||
import asyncio; \
|
||||
from blueprints.rag.logic import delete_all_documents, index_documents, index_obsidian_documents; \
|
||||
delete_all_documents(); \
|
||||
asyncio.run(index_documents()); \
|
||||
asyncio.run(index_obsidian_documents()); \
|
||||
print('Reindex complete')"
|
||||
|
||||
reindex-obsidian:
|
||||
$(COMPOSE) exec $(RAGGR) python -c "\
|
||||
import asyncio; \
|
||||
from blueprints.rag.logic import delete_documents_by_metadata, index_obsidian_documents; \
|
||||
delete_documents_by_metadata('source', 'obsidian'); \
|
||||
asyncio.run(index_obsidian_documents()); \
|
||||
print('Obsidian reindex complete')"
|
||||
|
||||
# Database migrations
|
||||
migrate:
|
||||
docker compose exec raggr aerich upgrade
|
||||
$(COMPOSE) exec $(RAGGR) aerich upgrade
|
||||
|
||||
migrate-new:
|
||||
@read -p "Migration name: " name; \
|
||||
docker compose exec raggr aerich migrate --name $$name
|
||||
@./scripts/migrate-new.sh
|
||||
|
||||
migrate-history:
|
||||
docker compose exec raggr aerich history
|
||||
$(COMPOSE) exec $(RAGGR) aerich history
|
||||
|
||||
# Tests
|
||||
test:
|
||||
|
||||
@@ -127,8 +127,8 @@ cp .env.example .env
|
||||
# Development (local PostgreSQL only)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Or full Docker deployment
|
||||
docker compose up -d
|
||||
# Or full Docker deployment (app runs as raggr-blue; see docs/deployment.md)
|
||||
docker compose --profile blue up -d
|
||||
```
|
||||
|
||||
4. **Access the application**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Streaming Implementation Summary
|
||||
|
||||
## Overview
|
||||
The `/stream-query` endpoint uses Server-Sent Events (SSE) to give real-time feedback
|
||||
during AI query processing: tool-execution status **and** token-by-token streaming of the
|
||||
assistant's answer.
|
||||
|
||||
## Backend (`blueprints/conversation/__init__.py`)
|
||||
|
||||
### `POST /api/conversation/stream-query`
|
||||
Streams SSE frames (`data: {json}\n\n`) by iterating `main_agent.astream_events(..., version="v2")`.
|
||||
Event types emitted to the client:
|
||||
|
||||
- `tool_start` — a tool began (`{ "type": "tool_start", "tool": <name> }`), from `on_tool_start`.
|
||||
- `tool_end` — a tool finished, from `on_tool_end`.
|
||||
- `content` — an incremental answer chunk (`{ "type": "content", "delta": <text> }`), from
|
||||
`on_chat_model_stream`. Chunks are accumulated server-side into `streamed`.
|
||||
- `response` — the final, authoritative message (`{ "type": "response", "message": <full text> }`).
|
||||
Sourced from `on_chain_end` (falling back to the accumulated `streamed` tokens). This is the
|
||||
text that is persisted to the DB via `add_message_to_conversation(..., speaker="simba")`.
|
||||
- `error` — an error message.
|
||||
- The stream terminates with `data: [DONE]`.
|
||||
|
||||
The user's message is persisted before streaming begins; the assistant message is persisted once
|
||||
the stream completes.
|
||||
|
||||
### `POST /api/conversation/query` (legacy, non-streaming)
|
||||
Backwards-compatible endpoint that returns the complete response in one JSON payload
|
||||
(`{ "response": <text> }`). Kept for compatibility; the frontend uses `/stream-query`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### API service (`src/api/conversationService.ts`)
|
||||
- `streamQuery(query, conversation_id, onEvent, signal?, imageKey?)` — POSTs to `/stream-query`
|
||||
and drives the response through `_readSSEStream()`.
|
||||
- `_readSSEStream()` — reads the `ReadableStream`, splits on `\n\n`, parses `data:` frames into
|
||||
`SSEEvent` objects, and stops on `[DONE]`.
|
||||
- `SSEEvent` union: `tool_start | tool_end | content | response | error`.
|
||||
|
||||
### Chat state (`src/hooks/useChat.ts`)
|
||||
`sendMessage()` consumes the stream and maintains a unified `messages` array:
|
||||
|
||||
- `tool_start` → appends a `tool` bubble (mapped to a friendly label via `TOOL_MESSAGES`) and
|
||||
resets the streaming pointer so any text after a tool call starts a fresh bubble.
|
||||
- `content` → on the first delta, appends a new `simba` bubble and flips `streamingActiveRef` /
|
||||
`streaming`; subsequent deltas append to that bubble's text.
|
||||
- `response` → overwrites the live `simba` bubble with the authoritative message (guaranteeing
|
||||
parity with what was persisted), or appends it if nothing streamed (e.g. an instant answer).
|
||||
- `error` → surfaces a user-facing error.
|
||||
|
||||
A `streaming` boolean is exposed so the UI can hide the redundant loading indicator once tokens
|
||||
start arriving.
|
||||
|
||||
### Components
|
||||
- `ChatScreen.tsx` — renders `messages` (`ToolBubble` / `AnswerBubble` / `QuestionBubble`) and
|
||||
shows the standalone loading-dots `AnswerBubble` only before the first token
|
||||
(`(isLoading && !streaming) || messagesLoading`).
|
||||
- `AnswerBubble.tsx` — renders the assistant message as markdown, or animated loading dots when
|
||||
`loading` is set.
|
||||
- `ToolBubble.tsx` — renders tool-status messages as inline badges.
|
||||
|
||||
## User Experience
|
||||
1. User sends a message.
|
||||
2. Loading dots appear.
|
||||
3. Tool status bubbles appear as tools run (e.g. "Searching Simba's records...").
|
||||
4. The answer streams in word-by-word into a single assistant bubble.
|
||||
5. On completion the bubble is reconciled with the persisted final text.
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
# Backend
|
||||
make test # or: .venv/bin/python -m pytest tests/
|
||||
|
||||
# Frontend
|
||||
cd raggr-frontend && yarn build
|
||||
|
||||
# Full stack (manual)
|
||||
docker compose up --build
|
||||
```
|
||||
@@ -1,9 +1,18 @@
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from quart import Quart, jsonify, render_template, request, send_from_directory
|
||||
from quart import (
|
||||
Quart,
|
||||
Response,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
)
|
||||
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required
|
||||
from tortoise import Tortoise
|
||||
|
||||
@@ -13,9 +22,15 @@ import blueprints.email
|
||||
import blueprints.rag
|
||||
import blueprints.users
|
||||
import blueprints.whatsapp
|
||||
import blueprints.imessage
|
||||
import blueprints.scheduled_messages
|
||||
import blueprints.cron
|
||||
import blueprints.beli
|
||||
import blueprints.users.models
|
||||
from config.db import TORTOISE_CONFIG
|
||||
from main import consult_simba_oracle
|
||||
from metrics import render_metrics
|
||||
from blueprints.conversation.agents import build_agent
|
||||
from utils.homebox_mcp import load_homebox_tools
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -50,6 +65,31 @@ app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
||||
app.register_blueprint(blueprints.email.email_blueprint)
|
||||
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
|
||||
app.register_blueprint(blueprints.imessage.imessage_blueprint)
|
||||
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
|
||||
app.register_blueprint(blueprints.cron.cron_blueprint)
|
||||
app.register_blueprint(blueprints.beli.beli_blueprint)
|
||||
|
||||
|
||||
async def _obsidian_sync_loop():
|
||||
"""Background task that incrementally syncs Obsidian documents to pgvector."""
|
||||
from blueprints.rag.logic import sync_obsidian_documents
|
||||
|
||||
interval = int(os.getenv("OBSIDIAN_SYNC_INTERVAL", "60"))
|
||||
logger = logging.getLogger("obsidian_sync")
|
||||
logger.info(f"Obsidian sync watcher started (interval={interval}s)")
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = await sync_obsidian_documents()
|
||||
if result["added"] or result["updated"] or result["deleted"]:
|
||||
logger.info(
|
||||
f"Obsidian sync: {result['added']} added, "
|
||||
f"{result['updated']} updated, {result['deleted']} deleted"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Obsidian sync error")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
# Initialize Tortoise ORM with lifecycle hooks
|
||||
@@ -58,7 +98,36 @@ async def lifespan():
|
||||
logging.info("Initializing Tortoise ORM...")
|
||||
await Tortoise.init(config=TORTOISE_CONFIG)
|
||||
logging.info("Tortoise ORM initialized successfully")
|
||||
yield
|
||||
|
||||
import aiosqlite
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
checkpoint_path = os.getenv("CHECKPOINT_DB_PATH", "/app/checkpoints/checkpoints.db")
|
||||
async with aiosqlite.connect(checkpoint_path) as conn:
|
||||
checkpointer = AsyncSqliteSaver(conn)
|
||||
await checkpointer.setup()
|
||||
app.main_agent = build_agent(checkpointer)
|
||||
homebox_tools = await load_homebox_tools()
|
||||
app.admin_agent = (
|
||||
build_agent(checkpointer, homebox_tools)
|
||||
if homebox_tools
|
||||
else app.main_agent
|
||||
)
|
||||
logging.info(f"Agent checkpointer ready at {checkpoint_path}")
|
||||
|
||||
from utils.cronicle import ensure_beli_nightly_event
|
||||
|
||||
await ensure_beli_nightly_event()
|
||||
|
||||
watcher_task = None
|
||||
if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true":
|
||||
watcher_task = asyncio.create_task(_obsidian_sync_loop())
|
||||
|
||||
yield
|
||||
|
||||
if watcher_task is not None:
|
||||
watcher_task.cancel()
|
||||
|
||||
logging.info("Closing Tortoise ORM connections...")
|
||||
await Tortoise.close_connections()
|
||||
|
||||
@@ -69,46 +138,71 @@ async def static_files(filename):
|
||||
return await send_from_directory(app.static_folder, filename)
|
||||
|
||||
|
||||
# Allowed file extensions for static frontend assets
|
||||
ALLOWED_STATIC_EXTENSIONS = {
|
||||
".html",
|
||||
".css",
|
||||
".js",
|
||||
".svg",
|
||||
".png",
|
||||
".ico",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".txt",
|
||||
}
|
||||
|
||||
# JSON files explicitly allowed to be served (e.g. PWA manifest)
|
||||
ALLOWED_JSON_FILES = {"manifest.json"}
|
||||
|
||||
|
||||
# Prometheus metrics endpoint. Fail-closed: 403 unless a valid bearer token
|
||||
# matching METRICS_TOKEN is presented. Registered before the catch-all SPA
|
||||
# route so it is never shadowed.
|
||||
@app.get("/metrics")
|
||||
async def metrics_endpoint():
|
||||
expected = os.environ.get("METRICS_TOKEN", "")
|
||||
if not expected:
|
||||
return "unauthorized", 403
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return "unauthorized", 403
|
||||
provided = auth[len("Bearer ") :].strip()
|
||||
if not hmac.compare_digest(provided.encode(), expected.encode()):
|
||||
return "unauthorized", 403
|
||||
return Response(
|
||||
render_metrics(),
|
||||
status=200,
|
||||
content_type="text/plain; version=0.0.4",
|
||||
)
|
||||
|
||||
|
||||
# Serve the React app for all routes (catch-all)
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
async def serve_react_app(path):
|
||||
if path and os.path.exists(os.path.join(app.template_folder, path)):
|
||||
return await send_from_directory(app.template_folder, path)
|
||||
if path:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
basename = os.path.basename(path)
|
||||
allowed = ext in ALLOWED_STATIC_EXTENSIONS or (
|
||||
ext == ".json" and basename in ALLOWED_JSON_FILES
|
||||
)
|
||||
if allowed and os.path.exists(os.path.join(app.template_folder, path)):
|
||||
return await send_from_directory(app.template_folder, path)
|
||||
return await render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/query", methods=["POST"])
|
||||
@jwt_refresh_token_required
|
||||
async def query():
|
||||
current_user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||
data = await request.get_json()
|
||||
query = data.get("query")
|
||||
conversation_id = data.get("conversation_id")
|
||||
conversation = await blueprints.conversation.logic.get_conversation_by_id(
|
||||
conversation_id
|
||||
)
|
||||
await conversation.fetch_related("messages")
|
||||
await blueprints.conversation.logic.add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=query,
|
||||
speaker="user",
|
||||
user=user,
|
||||
)
|
||||
|
||||
transcript = await blueprints.conversation.logic.get_conversation_transcript(
|
||||
user=user, conversation=conversation
|
||||
)
|
||||
|
||||
response = consult_simba_oracle(input=query, transcript=transcript)
|
||||
await blueprints.conversation.logic.add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=response,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
return jsonify({"response": response})
|
||||
# Health check used by the Docker healthcheck and Caddy's active health checks
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
async def health():
|
||||
try:
|
||||
await Tortoise.get_connection("default").execute_query("SELECT 1")
|
||||
except Exception:
|
||||
return jsonify({"status": "unhealthy"}), 503
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/messages", methods=["GET"])
|
||||
@@ -135,17 +229,10 @@ async def get_messages():
|
||||
}
|
||||
)
|
||||
|
||||
name = conversation.name
|
||||
if len(messages) > 8:
|
||||
name = await blueprints.conversation.logic.rename_conversation(
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(conversation.id),
|
||||
"name": name,
|
||||
"name": conversation.name,
|
||||
"messages": messages,
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from quart_jwt_extended import get_jwt_identity, jwt_refresh_token_required
|
||||
|
||||
from .logic import (
|
||||
MAX_PROFILES_PER_USER,
|
||||
_get_access_token,
|
||||
_refresh_friend_sample,
|
||||
refresh_profile,
|
||||
schedule_nightly_refresh,
|
||||
)
|
||||
from .models import BeliProfile, BeliRestaurant, BeliStatus
|
||||
|
||||
beli_blueprint = Blueprint("beli", __name__, url_prefix="/api/beli")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_cron_secret() -> bool:
|
||||
expected = os.environ.get("CRON_SECRET", "")
|
||||
if not expected:
|
||||
return False
|
||||
provided = request.headers.get("X-Cron-Secret", "")
|
||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
|
||||
def _serialize_profile(profile: BeliProfile) -> dict:
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"beli_email": profile.beli_email,
|
||||
"beli_user_id": profile.beli_user_id,
|
||||
"enabled": profile.enabled,
|
||||
"last_refreshed_at": (
|
||||
profile.last_refreshed_at.isoformat() if profile.last_refreshed_at else None
|
||||
),
|
||||
"last_error": profile.last_error,
|
||||
"created_at": profile.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_restaurant(r: BeliRestaurant) -> dict:
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"profile_id": str(r.profile_id),
|
||||
"beli_business_id": r.beli_business_id,
|
||||
"name": r.name,
|
||||
"city": r.city,
|
||||
"neighborhood": r.neighborhood,
|
||||
"cuisine": r.cuisine,
|
||||
"price_range": r.price_range,
|
||||
"status": r.status.value,
|
||||
"rank_index": r.rank_index,
|
||||
"score": float(r.score) if r.score is not None else None,
|
||||
"notes": r.notes,
|
||||
"tags": r.tags,
|
||||
"fetched_at": r.fetched_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@beli_blueprint.route("/profiles", methods=["GET"])
|
||||
@jwt_refresh_token_required
|
||||
async def list_profiles():
|
||||
user_id = get_jwt_identity()
|
||||
profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all()
|
||||
return jsonify([_serialize_profile(p) for p in profiles])
|
||||
|
||||
|
||||
@beli_blueprint.route("/profiles", methods=["POST"])
|
||||
@jwt_refresh_token_required
|
||||
async def create_profile():
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
count = await BeliProfile.filter(user_id=user_id).count()
|
||||
if count >= MAX_PROFILES_PER_USER:
|
||||
return (
|
||||
jsonify(
|
||||
{"error": f"Maximum {MAX_PROFILES_PER_USER} Beli profiles per user"}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
email = (data.get("beli_email") or "").strip()
|
||||
password = (data.get("beli_password") or "").strip()
|
||||
if not email or not password:
|
||||
return jsonify({"error": "beli_email and beli_password are required"}), 400
|
||||
|
||||
if await BeliProfile.exists(user_id=user_id, beli_email=email):
|
||||
return jsonify({"error": "A profile with this email already exists"}), 409
|
||||
|
||||
from utils.beli_service import authenticate
|
||||
|
||||
try:
|
||||
session = await authenticate(email, password)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Beli authentication failed: {e}"}), 400
|
||||
|
||||
profile = await BeliProfile.create(
|
||||
user_id=user_id,
|
||||
beli_email=email,
|
||||
beli_password=password,
|
||||
beli_user_id=session["user_id"],
|
||||
beli_refresh_token=session["refresh_token"],
|
||||
enabled=True,
|
||||
)
|
||||
return jsonify(_serialize_profile(profile)), 201
|
||||
|
||||
|
||||
@beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"])
|
||||
@jwt_refresh_token_required
|
||||
async def delete_profile(profile_id: str):
|
||||
user_id = get_jwt_identity()
|
||||
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
|
||||
if not profile:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
await BeliRestaurant.filter(profile=profile).delete()
|
||||
await profile.delete()
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
|
||||
@beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"])
|
||||
@jwt_refresh_token_required
|
||||
async def refresh_profile_endpoint(profile_id: str):
|
||||
user_id = get_jwt_identity()
|
||||
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
|
||||
if not profile:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
result = await refresh_profile(str(profile.id))
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@beli_blueprint.route("/restaurants", methods=["GET"])
|
||||
@jwt_refresh_token_required
|
||||
async def list_restaurants():
|
||||
user_id = get_jwt_identity()
|
||||
profile_ids = [
|
||||
p.id for p in await BeliProfile.filter(user_id=user_id).only("id").all()
|
||||
]
|
||||
|
||||
qs = BeliRestaurant.filter(profile_id__in=profile_ids)
|
||||
|
||||
status_filter = request.args.get("status")
|
||||
city_filter = request.args.get("city")
|
||||
if status_filter:
|
||||
try:
|
||||
qs = qs.filter(status=BeliStatus(status_filter))
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid status: {status_filter}"}), 400
|
||||
if city_filter:
|
||||
qs = qs.filter(city__icontains=city_filter)
|
||||
|
||||
restaurants = await qs.order_by("rank_index", "name").all()
|
||||
return jsonify([_serialize_restaurant(r) for r in restaurants])
|
||||
|
||||
|
||||
@beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"])
|
||||
@jwt_refresh_token_required
|
||||
async def refresh_friends_endpoint(profile_id: str):
|
||||
user_id = get_jwt_identity()
|
||||
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
|
||||
if not profile:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
try:
|
||||
access_token = await _get_access_token(profile)
|
||||
upserted = await _refresh_friend_sample(profile, access_token)
|
||||
return jsonify({"ok": True, "upserted": upserted})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@beli_blueprint.route("/cron/refresh", methods=["POST"])
|
||||
async def cron_nightly_refresh():
|
||||
if not _check_cron_secret():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
result = await schedule_nightly_refresh()
|
||||
return jsonify(result)
|
||||
@@ -0,0 +1,252 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.beli_service import (
|
||||
RANKING_CATEGORIES,
|
||||
REQUEST_SPACING,
|
||||
authenticate,
|
||||
extract_business,
|
||||
fetch_bookmarks,
|
||||
fetch_following,
|
||||
fetch_user_rankings,
|
||||
is_token_expired,
|
||||
refresh_access_token,
|
||||
)
|
||||
|
||||
from tortoise.functions import Max
|
||||
|
||||
from .models import BeliProfile, BeliRestaurant, BeliStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PROFILES_PER_USER = 3
|
||||
NIGHTLY_BATCH_SIZE = 3
|
||||
NIGHTLY_WINDOW_SECONDS = 4 * 3600 # spread refreshes across a 4-hour window
|
||||
FRIEND_SAMPLE_SIZE = 5 # friends to randomly refresh per profile per night
|
||||
|
||||
|
||||
async def _get_access_token(profile: BeliProfile) -> str:
|
||||
"""Return a live access token, refreshing or re-authenticating as needed."""
|
||||
if profile.beli_refresh_token and not is_token_expired(profile.beli_refresh_token):
|
||||
try:
|
||||
token = await refresh_access_token(profile.beli_refresh_token)
|
||||
return token
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Refresh token failed for profile %s, falling back to password",
|
||||
profile.id,
|
||||
)
|
||||
|
||||
session = await authenticate(profile.beli_email, profile.beli_password)
|
||||
profile.beli_user_id = session["user_id"]
|
||||
profile.beli_refresh_token = session["refresh_token"]
|
||||
await profile.save()
|
||||
return session["access_token"]
|
||||
|
||||
|
||||
async def _refresh_friend_sample(profile: BeliProfile, access_token: str) -> int:
|
||||
"""Fetch rankings for a random sample of followed users and upsert them."""
|
||||
try:
|
||||
following = await fetch_following(access_token, profile.beli_user_id)
|
||||
except Exception as e:
|
||||
logger.debug("Beli following fetch skipped for profile %s: %s", profile.id, e)
|
||||
return 0
|
||||
|
||||
if not following:
|
||||
return 0
|
||||
|
||||
# Pick the FRIEND_SAMPLE_SIZE stalest friends — never-fetched first, then oldest
|
||||
fetched_rows = (
|
||||
await BeliRestaurant.filter(profile=profile)
|
||||
.exclude(friend_beli_user_id="")
|
||||
.group_by("friend_beli_user_id")
|
||||
.annotate(last_fetched=Max("fetched_at"))
|
||||
.values("friend_beli_user_id", "last_fetched")
|
||||
)
|
||||
last_fetched: dict[str, datetime] = {
|
||||
r["friend_beli_user_id"]: r["last_fetched"] for r in fetched_rows
|
||||
}
|
||||
_epoch = datetime.fromtimestamp(0, tz=timezone.utc)
|
||||
sample = sorted(
|
||||
following,
|
||||
key=lambda f: last_fetched.get(str(f.get("id") or ""), _epoch),
|
||||
)[:FRIEND_SAMPLE_SIZE]
|
||||
upserted = 0
|
||||
|
||||
for friend in sample:
|
||||
friend_id = str(friend.get("id") or "")
|
||||
friend_name = friend.get("full_name") or friend.get("username") or friend_id
|
||||
if not friend_id:
|
||||
continue
|
||||
|
||||
for category in RANKING_CATEGORIES:
|
||||
await asyncio.sleep(REQUEST_SPACING)
|
||||
try:
|
||||
results = await fetch_user_rankings(access_token, friend_id, category)
|
||||
except Exception as e:
|
||||
# 405 = private profile; log quietly and skip
|
||||
logger.debug(
|
||||
"Beli friend ranking skipped (%s/%s): %s", friend_id, category, e
|
||||
)
|
||||
continue
|
||||
|
||||
for idx, item in enumerate(results):
|
||||
biz = extract_business(item, rank_index=idx + 1)
|
||||
if not biz:
|
||||
continue
|
||||
await BeliRestaurant.update_or_create(
|
||||
profile=profile,
|
||||
beli_business_id=biz["beli_business_id"],
|
||||
status=BeliStatus.BEEN,
|
||||
friend_beli_user_id=friend_id,
|
||||
beli_category=category,
|
||||
defaults={
|
||||
"friend_name": friend_name,
|
||||
"name": biz["name"],
|
||||
"city": biz["city"],
|
||||
"neighborhood": biz["neighborhood"],
|
||||
"cuisine": biz["cuisine"],
|
||||
"price_range": biz["price_range"],
|
||||
"rank_index": biz["rank_index"],
|
||||
"score": biz["score"],
|
||||
"notes": None,
|
||||
"tags": biz["tags"],
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
if upserted:
|
||||
logger.info(
|
||||
"Beli profile %s: %d friend restaurants upserted from %d friends",
|
||||
profile.id,
|
||||
upserted,
|
||||
len(sample),
|
||||
)
|
||||
return upserted
|
||||
|
||||
|
||||
async def refresh_profile(profile_id: str) -> dict:
|
||||
"""Fetch the latest rankings and bookmarks for a profile and upsert them."""
|
||||
profile = await BeliProfile.get_or_none(id=profile_id)
|
||||
if not profile or not profile.enabled:
|
||||
return {"skipped": True}
|
||||
|
||||
try:
|
||||
access_token = await _get_access_token(profile)
|
||||
|
||||
upserted = 0
|
||||
|
||||
# Fetch all ranking categories (restaurants the user has been to and ranked)
|
||||
for category in RANKING_CATEGORIES:
|
||||
await asyncio.sleep(REQUEST_SPACING)
|
||||
results = await fetch_user_rankings(
|
||||
access_token, profile.beli_user_id, category
|
||||
)
|
||||
for idx, item in enumerate(results):
|
||||
biz = extract_business(item, rank_index=idx + 1)
|
||||
if not biz:
|
||||
continue
|
||||
await BeliRestaurant.update_or_create(
|
||||
profile=profile,
|
||||
beli_business_id=biz["beli_business_id"],
|
||||
status=BeliStatus.BEEN,
|
||||
friend_beli_user_id="",
|
||||
beli_category=category,
|
||||
defaults={
|
||||
"name": biz["name"],
|
||||
"city": biz["city"],
|
||||
"neighborhood": biz["neighborhood"],
|
||||
"cuisine": biz["cuisine"],
|
||||
"price_range": biz["price_range"],
|
||||
"rank_index": biz["rank_index"],
|
||||
"score": biz["score"],
|
||||
"notes": biz["notes"],
|
||||
"tags": biz["tags"],
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
# Fetch bookmarks (want-to-try list) — non-fatal if unavailable
|
||||
await asyncio.sleep(REQUEST_SPACING)
|
||||
try:
|
||||
bookmarks = await fetch_bookmarks(access_token)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Beli bookmarks fetch failed for profile %s: %s", profile_id, e
|
||||
)
|
||||
bookmarks = []
|
||||
for item in bookmarks:
|
||||
biz = extract_business(item)
|
||||
if not biz:
|
||||
continue
|
||||
await BeliRestaurant.update_or_create(
|
||||
profile=profile,
|
||||
beli_business_id=biz["beli_business_id"],
|
||||
status=BeliStatus.WANT_TO_TRY,
|
||||
friend_beli_user_id="",
|
||||
beli_category="",
|
||||
defaults={
|
||||
"name": biz["name"],
|
||||
"city": biz["city"],
|
||||
"neighborhood": biz["neighborhood"],
|
||||
"cuisine": biz["cuisine"],
|
||||
"price_range": biz["price_range"],
|
||||
"rank_index": None,
|
||||
"score": None,
|
||||
"notes": biz["notes"],
|
||||
"tags": biz["tags"],
|
||||
},
|
||||
)
|
||||
upserted += 1
|
||||
|
||||
profile.last_refreshed_at = datetime.now(timezone.utc)
|
||||
profile.last_error = None
|
||||
await profile.save()
|
||||
|
||||
logger.info(
|
||||
"Beli profile %s refreshed: %d restaurants upserted", profile_id, upserted
|
||||
)
|
||||
return {"ok": True, "upserted": upserted}
|
||||
|
||||
except Exception as e:
|
||||
profile.last_error = str(e)
|
||||
await profile.save()
|
||||
logger.error("Failed to refresh Beli profile %s: %s", profile_id, e)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _refresh_after_delay(profile_id: str, delay: float) -> None:
|
||||
await asyncio.sleep(delay)
|
||||
profile = await BeliProfile.get_or_none(id=profile_id)
|
||||
if not profile:
|
||||
return
|
||||
await refresh_profile(profile_id)
|
||||
# Friend sampling only runs during nightly scheduled refreshes, not manual ones
|
||||
try:
|
||||
access_token = await _get_access_token(profile)
|
||||
await _refresh_friend_sample(profile, access_token)
|
||||
except Exception as e:
|
||||
logger.warning("Friend sample failed for profile %s: %s", profile_id, e)
|
||||
|
||||
|
||||
async def schedule_nightly_refresh() -> dict:
|
||||
"""Pick the NIGHTLY_BATCH_SIZE stalest profiles and schedule them with random delays."""
|
||||
profiles = (
|
||||
await BeliProfile.filter(enabled=True)
|
||||
.order_by("last_refreshed_at")
|
||||
.limit(NIGHTLY_BATCH_SIZE)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not profiles:
|
||||
logger.info("No enabled Beli profiles to refresh")
|
||||
return {"scheduled": 0}
|
||||
|
||||
for profile in profiles:
|
||||
delay = random.uniform(0, NIGHTLY_WINDOW_SECONDS)
|
||||
asyncio.create_task(_refresh_after_delay(str(profile.id), delay))
|
||||
logger.info("Scheduled Beli refresh for profile %s in %.0fs", profile.id, delay)
|
||||
|
||||
return {"scheduled": len(profiles)}
|
||||
@@ -0,0 +1,66 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from blueprints.email.crypto_service import EncryptedTextField
|
||||
|
||||
|
||||
class BeliStatus(str, enum.Enum):
|
||||
BEEN = "been"
|
||||
WANT_TO_TRY = "want_to_try"
|
||||
|
||||
|
||||
class BeliProfile(Model):
|
||||
id = fields.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||
user = fields.ForeignKeyField("models.User", related_name="beli_profiles")
|
||||
beli_email = fields.CharField(max_length=255)
|
||||
beli_password = EncryptedTextField()
|
||||
beli_user_id = fields.CharField(max_length=255, null=True)
|
||||
# refresh token lasts 7 days; we store it encrypted to avoid re-authenticating each run
|
||||
beli_refresh_token = EncryptedTextField(null=True)
|
||||
last_refreshed_at = fields.DatetimeField(null=True)
|
||||
last_error = fields.TextField(null=True)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "beli_profiles"
|
||||
unique_together = (("user", "beli_email"),)
|
||||
|
||||
|
||||
class BeliRestaurant(Model):
|
||||
id = fields.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||
profile = fields.ForeignKeyField("models.BeliProfile", related_name="restaurants")
|
||||
beli_business_id = fields.CharField(max_length=255)
|
||||
# Empty string = profile owner's own ranking; UUID string = a followed user's ranking
|
||||
friend_beli_user_id = fields.CharField(max_length=255, default="")
|
||||
friend_name = fields.CharField(max_length=255, null=True)
|
||||
# Beli category code: RES / DES / BAR / BAK; empty for bookmarks
|
||||
beli_category = fields.CharField(max_length=10, default="")
|
||||
name = fields.CharField(max_length=500)
|
||||
city = fields.CharField(max_length=255, null=True)
|
||||
neighborhood = fields.CharField(max_length=255, null=True)
|
||||
cuisine = fields.JSONField(default=list)
|
||||
price_range = fields.CharField(max_length=10, null=True)
|
||||
status = fields.CharEnumField(enum_type=BeliStatus, max_length=20)
|
||||
rank_index = fields.IntField(null=True)
|
||||
score = fields.DecimalField(max_digits=5, decimal_places=2, null=True)
|
||||
notes = fields.TextField(null=True)
|
||||
tags = fields.JSONField(default=list)
|
||||
fetched_at = fields.DatetimeField(auto_now=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "beli_restaurants"
|
||||
unique_together = (
|
||||
(
|
||||
"profile",
|
||||
"beli_business_id",
|
||||
"status",
|
||||
"friend_beli_user_id",
|
||||
"beli_category",
|
||||
),
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
import datetime
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from quart import Blueprint, jsonify, make_response, request
|
||||
@@ -10,57 +13,116 @@ from quart_jwt_extended import (
|
||||
)
|
||||
|
||||
import blueprints.users.models
|
||||
from utils.image_process import analyze_user_image
|
||||
from metrics import count_result, observe_response, time_response
|
||||
from utils.image_process import image_content_block
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
from utils.image_upload import ImageValidationError, process_image
|
||||
from utils.s3_client import generate_presigned_url as s3_presigned_url
|
||||
from utils.s3_client import get_image as s3_get_image
|
||||
from utils.s3_client import upload_image as s3_upload_image
|
||||
|
||||
from .agents import main_agent
|
||||
from .logic import (
|
||||
add_message_to_conversation,
|
||||
get_conversation_by_id,
|
||||
rename_conversation,
|
||||
get_agent_for,
|
||||
persist_agent_tool_messages,
|
||||
tool_result_text,
|
||||
)
|
||||
from .memory import build_system_prompt_with_memories
|
||||
from .models import (
|
||||
Conversation,
|
||||
PydConversation,
|
||||
PydListConversation,
|
||||
Speaker,
|
||||
)
|
||||
from .prompts import SIMBA_SYSTEM_PROMPT
|
||||
|
||||
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
_THINK_OPEN = "<think>"
|
||||
_THINK_CLOSE = "</think>"
|
||||
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||
|
||||
|
||||
def _route_content(
|
||||
buf: str, text: str, in_thinking: bool
|
||||
) -> tuple[str, str, bool, str]:
|
||||
"""Split a streaming content chunk into (thinking_text, content_text).
|
||||
|
||||
Handles tags split across chunk boundaries by keeping a small trailing
|
||||
buffer. Returns the updated in_thinking state and any leftover buffer.
|
||||
"""
|
||||
combined = buf + text
|
||||
thinking_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
|
||||
while combined:
|
||||
tag, hold = (
|
||||
(_THINK_CLOSE, len(_THINK_CLOSE) - 1)
|
||||
if in_thinking
|
||||
else (_THINK_OPEN, len(_THINK_OPEN) - 1)
|
||||
)
|
||||
idx = combined.find(tag)
|
||||
if idx == -1:
|
||||
# Tag not yet complete — hold back enough bytes to catch a split.
|
||||
if len(combined) <= hold:
|
||||
return (
|
||||
"".join(thinking_parts),
|
||||
"".join(content_parts),
|
||||
in_thinking,
|
||||
combined,
|
||||
)
|
||||
safe, leftover = combined[:-hold], combined[-hold:]
|
||||
(thinking_parts if in_thinking else content_parts).append(safe)
|
||||
return (
|
||||
"".join(thinking_parts),
|
||||
"".join(content_parts),
|
||||
in_thinking,
|
||||
leftover,
|
||||
)
|
||||
(thinking_parts if in_thinking else content_parts).append(combined[:idx])
|
||||
combined = combined[idx + len(tag) :]
|
||||
in_thinking = not in_thinking
|
||||
|
||||
return "".join(thinking_parts), "".join(content_parts), in_thinking, ""
|
||||
|
||||
|
||||
conversation_blueprint = Blueprint(
|
||||
"conversation_api", __name__, url_prefix="/api/conversation"
|
||||
)
|
||||
|
||||
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
||||
# Detached persistence tasks. A strong reference keeps them from being garbage
|
||||
# collected mid-flight when the SSE stream is cancelled by a client disconnect.
|
||||
_persist_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _build_messages_payload(
|
||||
conversation, query_text: str, image_description: str | None = None
|
||||
) -> list:
|
||||
recent_messages = (
|
||||
conversation.messages[-10:]
|
||||
if len(conversation.messages) > 10
|
||||
else conversation.messages
|
||||
)
|
||||
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
||||
for msg in recent_messages[:-1]: # Exclude the message we just added
|
||||
role = "user" if msg.speaker == "user" else "assistant"
|
||||
text = msg.text
|
||||
if msg.image_key and role == "user":
|
||||
text = f"[User sent an image]\n{text}"
|
||||
messages_payload.append({"role": role, "content": text})
|
||||
def _persist_in_background(coro) -> asyncio.Task:
|
||||
task = asyncio.create_task(coro)
|
||||
_persist_tasks.add(task)
|
||||
task.add_done_callback(_persist_tasks.discard)
|
||||
return task
|
||||
|
||||
# Build the current user message with optional image description
|
||||
if image_description:
|
||||
content = f"[Image analysis: {image_description}]"
|
||||
if query_text:
|
||||
content = f"{query_text}\n\n{content}"
|
||||
|
||||
async def _get_owned_conversation(conversation_id, user_id) -> Conversation | None:
|
||||
"""Fetch a conversation only if it belongs to the given user."""
|
||||
return await Conversation.get_or_none(id=conversation_id, user_id=user_id)
|
||||
|
||||
|
||||
def _build_user_message(
|
||||
query_text: str,
|
||||
image_block: dict | None = None,
|
||||
) -> dict:
|
||||
"""Build the single new user message for this turn.
|
||||
|
||||
History is restored by the LangGraph checkpointer; only the current
|
||||
message is passed to avoid duplicating prior turns in checkpoint state.
|
||||
"""
|
||||
if image_block:
|
||||
content = [
|
||||
{"type": "text", "text": query_text or "The user sent this image."},
|
||||
image_block,
|
||||
]
|
||||
else:
|
||||
content = query_text
|
||||
messages_payload.append({"role": "user", "content": content})
|
||||
return messages_payload
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
|
||||
@conversation_blueprint.post("/query")
|
||||
@@ -71,8 +133,9 @@ async def query():
|
||||
data = await request.get_json()
|
||||
query = data.get("query")
|
||||
conversation_id = data.get("conversation_id")
|
||||
conversation = await get_conversation_by_id(conversation_id)
|
||||
await conversation.fetch_related("messages")
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=query,
|
||||
@@ -80,18 +143,30 @@ async def query():
|
||||
user=user,
|
||||
)
|
||||
|
||||
messages_payload = _build_messages_payload(conversation, query)
|
||||
payload = {"messages": messages_payload}
|
||||
with time_response(stream=False):
|
||||
system_prompt = await build_system_prompt_with_memories(str(user.id))
|
||||
payload = {"messages": [_build_user_message(query)]}
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
response = await main_agent.ainvoke(payload)
|
||||
message = response.get("messages", [])[-1].content
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
return jsonify({"response": message})
|
||||
response = await get_agent_for(user).ainvoke(payload, config=agent_config)
|
||||
message = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
return jsonify({"response": message})
|
||||
|
||||
|
||||
@conversation_blueprint.post("/upload-image")
|
||||
@@ -142,8 +217,9 @@ async def stream_query():
|
||||
query_text = data.get("query")
|
||||
conversation_id = data.get("conversation_id")
|
||||
image_key = data.get("image_key")
|
||||
conversation = await get_conversation_by_id(conversation_id)
|
||||
await conversation.fetch_related("messages")
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=query_text or "",
|
||||
@@ -152,33 +228,175 @@ async def stream_query():
|
||||
image_key=image_key,
|
||||
)
|
||||
|
||||
# If an image was uploaded, analyze it with the vision model
|
||||
image_description = None
|
||||
# If an image was uploaded, attach it to the agent request directly
|
||||
image_block = None
|
||||
if image_key:
|
||||
try:
|
||||
image_bytes, _ = await s3_get_image(image_key)
|
||||
image_description = await analyze_user_image(image_bytes)
|
||||
logging.info(f"Image analysis complete for {image_key}")
|
||||
image_block = image_content_block(image_bytes)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to analyze image: {e}")
|
||||
image_description = "[Image could not be analyzed]"
|
||||
logging.error(f"Failed to load image {image_key}: {e}")
|
||||
query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip()
|
||||
|
||||
messages_payload = _build_messages_payload(
|
||||
conversation, query_text or "", image_description
|
||||
)
|
||||
payload = {"messages": messages_payload}
|
||||
system_prompt = await build_system_prompt_with_memories(str(user.id))
|
||||
payload = {"messages": [_build_user_message(query_text or "", image_block)]}
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
# Capture the agent reference while the request/app context is still active.
|
||||
# Quart exits the app context before iterating the SSE response body, so
|
||||
# current_app cannot be accessed inside event_generator().
|
||||
main_agent = get_agent_for(user)
|
||||
|
||||
async def event_generator():
|
||||
final_message = None
|
||||
streamed = ""
|
||||
# Tag-routing state for <think>…</think> blocks.
|
||||
in_thinking = False
|
||||
tag_buf = ""
|
||||
# Fallback heartbeat for models that emit empty chunks before thinking text.
|
||||
last_thinking = 0.0
|
||||
turn_has_content = False
|
||||
|
||||
# Timing / observability
|
||||
t0 = time.monotonic()
|
||||
llm_start_at: float | None = None
|
||||
first_content_at: float | None = None
|
||||
tool_timers: dict[str, float] = {} # run_id -> start time
|
||||
tool_spans: list[dict] = []
|
||||
n_tokens = 0
|
||||
model_name: str | None = None
|
||||
cancelled = False
|
||||
|
||||
async def persist(text: str) -> None:
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=text,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
|
||||
def _log_summary(*, error: str | None = None) -> None:
|
||||
now = time.monotonic()
|
||||
thinking_ms = (
|
||||
round((first_content_at - llm_start_at) * 1000)
|
||||
if llm_start_at and first_content_at
|
||||
else None
|
||||
)
|
||||
ttft_ms = (
|
||||
round((first_content_at - t0) * 1000) if first_content_at else None
|
||||
)
|
||||
logging.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "stream_request",
|
||||
"conv": str(conversation.id),
|
||||
"user": user.username,
|
||||
"total_ms": round((now - t0) * 1000),
|
||||
"thinking_ms": thinking_ms,
|
||||
"ttft_ms": ttft_ms,
|
||||
"tokens": n_tokens,
|
||||
"tools": tool_spans,
|
||||
"model": model_name,
|
||||
"cancelled": cancelled,
|
||||
**({"error": error} if error else {}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async for event in main_agent.astream_events(payload, version="v2"):
|
||||
aiter = main_agent.astream_events(
|
||||
payload, version="v2", config=agent_config
|
||||
).__aiter__()
|
||||
while True:
|
||||
# Apply a short TTFT timeout only during the window between
|
||||
# on_chat_model_start and the first content token. Before the
|
||||
# LLM call starts (tool phase) and after the first token, let
|
||||
# the httpx read timeout handle stalls instead.
|
||||
if llm_start_at is not None and first_content_at is None:
|
||||
ttft_wait = TTFT_TIMEOUT - (time.monotonic() - llm_start_at)
|
||||
timeout = max(ttft_wait, 1.0)
|
||||
else:
|
||||
timeout = None
|
||||
|
||||
try:
|
||||
if timeout is not None:
|
||||
event = await asyncio.wait_for(
|
||||
aiter.__anext__(), timeout=timeout
|
||||
)
|
||||
else:
|
||||
event = await aiter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
_log_summary(error=f"TTFT timeout after {TTFT_TIMEOUT:.0f}s")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Simba took too long to respond. Please try again.', 'stalled': True})}\n\n"
|
||||
return
|
||||
|
||||
event_type = event.get("event")
|
||||
|
||||
if event_type == "on_tool_start":
|
||||
tool_timers[event["run_id"]] = time.monotonic()
|
||||
yield f"data: {json.dumps({'type': 'tool_start', 'tool': event['name']})}\n\n"
|
||||
|
||||
elif event_type == "on_tool_end":
|
||||
start = tool_timers.pop(event["run_id"], None)
|
||||
tool_spans.append(
|
||||
{
|
||||
"tool": event["name"],
|
||||
"ms": round((time.monotonic() - start) * 1000)
|
||||
if start
|
||||
else None,
|
||||
}
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'tool_end', 'tool': event['name']})}\n\n"
|
||||
# Persist tool output so later turns keep the fetched context
|
||||
output = event.get("data", {}).get("output")
|
||||
if output is not None:
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=tool_result_text(event["name"], output),
|
||||
speaker="tool",
|
||||
user=user,
|
||||
)
|
||||
|
||||
elif event_type == "on_chat_model_start":
|
||||
turn_has_content = False
|
||||
if llm_start_at is None:
|
||||
llm_start_at = time.monotonic()
|
||||
if model_name is None:
|
||||
model_name = event.get("metadata", {}).get(
|
||||
"ls_model_name"
|
||||
) or event.get("name")
|
||||
|
||||
elif event_type == "on_chat_model_stream":
|
||||
chunk = event.get("data", {}).get("chunk")
|
||||
raw = getattr(chunk, "content", None)
|
||||
if isinstance(raw, str) and raw:
|
||||
think_text, content_text, in_thinking, tag_buf = _route_content(
|
||||
tag_buf, raw, in_thinking
|
||||
)
|
||||
if think_text:
|
||||
yield f"data: {json.dumps({'type': 'thinking_delta', 'delta': think_text})}\n\n"
|
||||
if content_text:
|
||||
if first_content_at is None:
|
||||
first_content_at = time.monotonic()
|
||||
turn_has_content = True
|
||||
n_tokens += 1
|
||||
streamed += content_text
|
||||
yield f"data: {json.dumps({'type': 'content', 'delta': content_text})}\n\n"
|
||||
elif not turn_has_content:
|
||||
# Empty chunk before any content — heartbeat for models
|
||||
# that don't emit <think> tags at all.
|
||||
now = time.monotonic()
|
||||
if now - last_thinking >= 1.0:
|
||||
last_thinking = now
|
||||
yield f"data: {json.dumps({'type': 'thinking'})}\n\n"
|
||||
|
||||
elif event_type == "on_chain_end":
|
||||
output = event.get("data", {}).get("output")
|
||||
@@ -188,22 +406,34 @@ async def stream_query():
|
||||
last_msg = msgs[-1]
|
||||
content = getattr(last_msg, "content", None)
|
||||
if isinstance(content, str) and content:
|
||||
final_message = content
|
||||
final_message = _THINK_RE.sub("", content).strip()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
_log_summary()
|
||||
text = final_message or streamed
|
||||
if text:
|
||||
_persist_in_background(persist(text))
|
||||
raise
|
||||
except Exception as e:
|
||||
count_result(stream=True, result="error")
|
||||
_log_summary(error=str(e))
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
return
|
||||
|
||||
final_message = final_message or streamed
|
||||
if final_message:
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=final_message,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
# Persist via a detached, shielded task so a disconnect at this
|
||||
# point (after generation completes) still commits the answer.
|
||||
task = _persist_in_background(persist(final_message))
|
||||
await asyncio.shield(task)
|
||||
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
|
||||
|
||||
observe_response(stream=True, seconds=time.monotonic() - t0)
|
||||
count_result(stream=True, result="success")
|
||||
_log_summary()
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return await make_response(
|
||||
@@ -220,35 +450,31 @@ async def stream_query():
|
||||
@conversation_blueprint.route("/<conversation_id>")
|
||||
@jwt_refresh_token_required
|
||||
async def get_conversation(conversation_id: str):
|
||||
conversation = await Conversation.get(id=conversation_id)
|
||||
current_user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||
user_uuid = get_jwt_identity()
|
||||
conversation = await _get_owned_conversation(conversation_id, user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
await conversation.fetch_related("messages")
|
||||
|
||||
# Manually serialize the conversation with messages
|
||||
messages = []
|
||||
for msg in conversation.messages:
|
||||
# Tool messages hold raw fetched context for the LLM; only surface
|
||||
# their header line (e.g. "[web_search result]") in the UI.
|
||||
text = msg.text.split("\n", 1)[0] if msg.speaker == Speaker.TOOL else msg.text
|
||||
messages.append(
|
||||
{
|
||||
"id": str(msg.id),
|
||||
"text": msg.text,
|
||||
"text": text,
|
||||
"speaker": msg.speaker.value,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"image_key": msg.image_key,
|
||||
}
|
||||
)
|
||||
name = conversation.name
|
||||
if len(messages) > 8 and "datetime" in name.lower():
|
||||
name = await rename_conversation(
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
)
|
||||
print(name)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(conversation.id),
|
||||
"name": name,
|
||||
"name": conversation.name,
|
||||
"messages": messages,
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
@@ -262,7 +488,7 @@ async def create_conversation():
|
||||
user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||
conversation = await Conversation.create(
|
||||
name=f"{user.username} {datetime.datetime.now().timestamp}",
|
||||
name="New Conversation",
|
||||
user=user,
|
||||
)
|
||||
|
||||
@@ -275,7 +501,54 @@ async def create_conversation():
|
||||
async def get_all_conversations():
|
||||
user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||
conversations = Conversation.filter(user=user).order_by("-updated_at")
|
||||
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
||||
|
||||
query = Conversation.filter(user=user)
|
||||
|
||||
search = request.args.get("search", "").strip()
|
||||
if search:
|
||||
query = query.filter(name__icontains=search)
|
||||
|
||||
query = query.order_by("-updated_at")
|
||||
|
||||
# `limit` is optional: when omitted we return the full list (backward
|
||||
# compatible with any non-frontend callers). `offset` only applies with it.
|
||||
limit = request.args.get("limit", type=int)
|
||||
if limit is not None:
|
||||
offset = request.args.get("offset", default=0, type=int)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
serialized_conversations = await PydListConversation.from_queryset(query)
|
||||
|
||||
return jsonify(serialized_conversations.model_dump())
|
||||
|
||||
|
||||
@conversation_blueprint.patch("/<conversation_id>")
|
||||
@jwt_refresh_token_required
|
||||
async def rename_conversation(conversation_id: str):
|
||||
user_uuid = get_jwt_identity()
|
||||
body = await request.get_json()
|
||||
name = (body or {}).get("name", "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
|
||||
conversation = await _get_owned_conversation(conversation_id, user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
conversation.name = name[:255]
|
||||
await conversation.save()
|
||||
|
||||
serialized_conversation = await PydConversation.from_tortoise_orm(conversation)
|
||||
return jsonify(serialized_conversation.model_dump())
|
||||
|
||||
|
||||
@conversation_blueprint.delete("/<conversation_id>")
|
||||
@jwt_refresh_token_required
|
||||
async def delete_conversation(conversation_id: str):
|
||||
user_uuid = get_jwt_identity()
|
||||
conversation = await _get_owned_conversation(conversation_id, user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
await conversation.delete()
|
||||
return jsonify({"success": True})
|
||||
|
||||
+750
-186
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,54 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import tortoise.exceptions
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import ToolMessage
|
||||
from quart import current_app
|
||||
|
||||
import blueprints.users.models
|
||||
|
||||
from .models import Conversation, ConversationMessage, RenameConversationOutputSchema
|
||||
from .models import Conversation, ConversationMessage
|
||||
|
||||
# Cap persisted tool output so replayed history stays within the LLM context
|
||||
# budget (llama-server slots are 32k tokens; base prompt + tools already use ~6k).
|
||||
TOOL_RESULT_MAX_CHARS = 4000
|
||||
|
||||
|
||||
def tool_result_text(tool_name: str, output) -> str:
|
||||
"""Format a tool's output for persistence as a conversation message."""
|
||||
content = getattr(output, "content", output)
|
||||
if isinstance(content, (list, tuple)):
|
||||
content = "\n".join(str(item) for item in content)
|
||||
text = str(content)
|
||||
if len(text) > TOOL_RESULT_MAX_CHARS:
|
||||
text = text[:TOOL_RESULT_MAX_CHARS] + "\n[truncated]"
|
||||
return f"[{tool_name} result]\n{text}"
|
||||
|
||||
|
||||
def get_agent_for(user: blueprints.users.models.User):
|
||||
"""Admins get the agent with admin-only tools (Homebox); everyone else the main one."""
|
||||
if user.is_admin():
|
||||
return getattr(current_app, "admin_agent", current_app.main_agent)
|
||||
return current_app.main_agent
|
||||
|
||||
|
||||
async def persist_agent_tool_messages(
|
||||
conversation: Conversation,
|
||||
user: blueprints.users.models.User,
|
||||
response_messages: list,
|
||||
) -> None:
|
||||
"""Persist ToolMessages from an agent response as 'tool' conversation messages.
|
||||
|
||||
Keeps fetched context (web searches, RAG chunks, etc.) available to later
|
||||
turns, which otherwise only see the final text answers.
|
||||
"""
|
||||
for msg in response_messages:
|
||||
if isinstance(msg, ToolMessage):
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=tool_result_text(msg.name or "tool", msg),
|
||||
speaker="tool",
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
async def create_conversation(name: str = "") -> Conversation:
|
||||
@@ -17,19 +62,26 @@ async def add_message_to_conversation(
|
||||
speaker: str,
|
||||
user: blueprints.users.models.User,
|
||||
image_key: str | None = None,
|
||||
message_handle: str | None = None,
|
||||
) -> ConversationMessage:
|
||||
print(conversation, message, speaker)
|
||||
# Bump updated_at on every message so conversations sort by last activity.
|
||||
# `update_fields` bypasses the auto_now recompute, so set it explicitly.
|
||||
update_fields = ["updated_at"]
|
||||
conversation.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Name the conversation after the first user message
|
||||
if speaker == "user" and not await conversation.messages.all().exists():
|
||||
conversation.name = message[:100]
|
||||
await conversation.save()
|
||||
update_fields.append("name")
|
||||
|
||||
await conversation.save(update_fields=update_fields)
|
||||
|
||||
message = await ConversationMessage.create(
|
||||
text=message,
|
||||
speaker=speaker,
|
||||
conversation=conversation,
|
||||
image_key=image_key,
|
||||
message_handle=message_handle,
|
||||
)
|
||||
|
||||
return message
|
||||
@@ -48,11 +100,29 @@ async def get_the_only_conversation() -> Conversation:
|
||||
|
||||
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
|
||||
try:
|
||||
return await Conversation.get(user=user)
|
||||
conversation = await Conversation.get(user=user)
|
||||
except tortoise.exceptions.MultipleObjectsReturned:
|
||||
conversation = (
|
||||
await Conversation.filter(user=user).order_by("created_at").first()
|
||||
)
|
||||
except tortoise.exceptions.DoesNotExist:
|
||||
await Conversation.get_or_create(name=f"{user.username}'s chat", user=user)
|
||||
conversation = await Conversation.create(
|
||||
name=f"{user.username}'s chat", user=user
|
||||
)
|
||||
return conversation
|
||||
|
||||
return await Conversation.get(user=user)
|
||||
|
||||
async def get_conversation_for_channel(
|
||||
user: blueprints.users.models.User, channel: str
|
||||
) -> Conversation:
|
||||
conversation = await (
|
||||
Conversation.filter(user=user, channel=channel).order_by("-updated_at").first()
|
||||
)
|
||||
if conversation is None:
|
||||
conversation = await Conversation.create(
|
||||
name=f"{user.username}'s {channel} chat", user=user, channel=channel
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_conversation_by_id(id: str) -> Conversation:
|
||||
@@ -67,22 +137,3 @@ async def get_conversation_transcript(
|
||||
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
||||
|
||||
return "\n".join(messages)
|
||||
|
||||
|
||||
async def rename_conversation(
|
||||
user: blueprints.users.models.User,
|
||||
conversation: Conversation,
|
||||
) -> str:
|
||||
messages: str = await get_conversation_transcript(
|
||||
user=user, conversation=conversation
|
||||
)
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4o-mini")
|
||||
structured_llm = llm.with_structured_output(RenameConversationOutputSchema)
|
||||
|
||||
prompt = f"Summarize the following conversation into a sassy one-liner title:\n\n{messages}"
|
||||
response = structured_llm.invoke(prompt)
|
||||
new_name: str = response.get("title", "")
|
||||
conversation.name = new_name
|
||||
await conversation.save()
|
||||
return new_name
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from .models import UserMemory
|
||||
from .prompts import SIMBA_SYSTEM_PROMPT
|
||||
|
||||
|
||||
async def get_memories_for_user(user_id: str) -> list[str]:
|
||||
"""Return all memory content strings for a user, ordered by most recently updated."""
|
||||
memories = await UserMemory.filter(user_id=user_id).order_by("-updated_at")
|
||||
return [m.content for m in memories]
|
||||
|
||||
|
||||
async def build_system_prompt_with_memories(user_id: str) -> str:
|
||||
memories = await get_memories_for_user(user_id)
|
||||
if not memories:
|
||||
return SIMBA_SYSTEM_PROMPT
|
||||
block = "\n".join(f"- {m}" for m in memories)
|
||||
return f"{SIMBA_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{block}"
|
||||
|
||||
|
||||
async def save_memory(user_id: str, content: str) -> str:
|
||||
"""Save a new memory or touch an existing one (exact-match dedup)."""
|
||||
existing = await UserMemory.filter(user_id=user_id, content=content).first()
|
||||
if existing:
|
||||
existing.updated_at = None # auto_now=True will refresh it on save
|
||||
await existing.save(update_fields=["updated_at"])
|
||||
return "Memory already exists (refreshed)."
|
||||
|
||||
await UserMemory.create(user_id=user_id, content=content)
|
||||
return "Memory saved."
|
||||
@@ -1,5 +1,4 @@
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.contrib.pydantic import (
|
||||
@@ -9,15 +8,10 @@ from tortoise.contrib.pydantic import (
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenameConversationOutputSchema:
|
||||
title: str
|
||||
justification: str
|
||||
|
||||
|
||||
class Speaker(enum.Enum):
|
||||
USER = "user"
|
||||
SIMBA = "simba"
|
||||
TOOL = "tool"
|
||||
|
||||
|
||||
class Conversation(Model):
|
||||
@@ -28,6 +22,7 @@ class Conversation(Model):
|
||||
user: fields.ForeignKeyRelation = fields.ForeignKeyField(
|
||||
"models.User", related_name="conversations", null=True
|
||||
)
|
||||
channel = fields.CharField(max_length=20, default="web", null=True)
|
||||
|
||||
class Meta:
|
||||
table = "conversations"
|
||||
@@ -42,11 +37,23 @@ class ConversationMessage(Model):
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
speaker = fields.CharEnumField(enum_type=Speaker, max_length=10)
|
||||
image_key = fields.CharField(max_length=512, null=True, default=None)
|
||||
message_handle = fields.CharField(max_length=255, null=True, default=None)
|
||||
|
||||
class Meta:
|
||||
table = "conversation_messages"
|
||||
|
||||
|
||||
class UserMemory(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="memories")
|
||||
content = fields.TextField()
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "user_memories"
|
||||
|
||||
|
||||
PydConversationMessage = pydantic_model_creator(ConversationMessage)
|
||||
PydConversation = pydantic_model_creator(
|
||||
Conversation, name="Conversation", allow_cycles=True, exclude=("user",)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
SIMBA_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||
SIMBA_SYSTEM_PROMPT = """You are Simba, Ryan's helpful personal assistant. You're named after his orange cat. You have a warm, friendly personality with a light cat-themed touch, but your priority is always being genuinely useful — give thorough, detailed answers and think things through carefully. When asked about Simba the cat, you speak as him in first person. For everything else, you're just a great assistant who happens to have a cat's name.
|
||||
|
||||
SIMBA FACTS (as of January 2026):
|
||||
- Name: Simba
|
||||
@@ -40,6 +40,18 @@ You have access to Ryan's budget data through YNAB (You Need A Budget). When use
|
||||
- Use ynab_insights to provide spending trends, patterns, and recommendations
|
||||
Always use these tools when asked about budgets, spending, transactions, or financial health.
|
||||
|
||||
MEAL PLANNING (Mealie Integration):
|
||||
You have access to Ryan's meal plans, recipes, and shopping lists through Mealie. When users ask about food or meal planning, use the appropriate Mealie tools:
|
||||
- Use mealie_todays_meals to check what's planned for today
|
||||
- Use mealie_meal_plans to see the week's meal schedule or a custom date range
|
||||
- Use mealie_get_recipe to get full recipe details (ingredients, instructions)
|
||||
- Use mealie_shopping_lists to check what groceries are needed
|
||||
- Use mealie_create_meal_plan to add meals to the plan
|
||||
Always use these tools when asked about meals, recipes, what's for dinner, or shopping lists.
|
||||
|
||||
HOME INVENTORY (Homebox Integration):
|
||||
If homebox_* tools are available, use them for questions about household items: where something is stored, what's in a location, warranties, and maintenance. Search before creating items to avoid duplicates.
|
||||
|
||||
NOTES & RESEARCH (Obsidian Integration):
|
||||
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
|
||||
- Use obsidian_search_notes to search through your vault for relevant information
|
||||
@@ -49,9 +61,12 @@ You have access to Ryan's Obsidian vault through the Obsidian integration. When
|
||||
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
|
||||
|
||||
DAILY JOURNAL (Task Tracking):
|
||||
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
|
||||
You have access to Ryan's daily journal notes. Each note lives at 50 - Journal/YYYY/MM/YYYY-MM-DD.md and has two sections: tasks and log.
|
||||
- Use journal_get_today to read today's full daily note (tasks + log)
|
||||
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
|
||||
- Use journal_add_task to add a new task to today's (or a given date's) note
|
||||
- Use journal_complete_task to check off a task as done
|
||||
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""
|
||||
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete.
|
||||
|
||||
USER MEMORY:
|
||||
You can remember facts about the user across conversations using the save_user_memory tool. When a user explicitly asks you to remember something, or shares a meaningful preference or personal fact, save it. Saved memories will automatically appear at the end of this prompt in future conversations under "USER MEMORIES"."""
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from blueprints.scheduled_messages.models import MessageChannel
|
||||
from .models import CronJob
|
||||
|
||||
cron_blueprint = Blueprint("cron_api", __name__, url_prefix="/api/cron")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize(job: CronJob) -> dict:
|
||||
return {
|
||||
"id": str(job.id),
|
||||
"name": job.name,
|
||||
"description": job.description,
|
||||
"prompt": job.prompt,
|
||||
"recipient": job.recipient,
|
||||
"channel": job.channel.value,
|
||||
"enabled": job.enabled,
|
||||
"last_run_at": job.last_run_at.isoformat() if job.last_run_at else None,
|
||||
"last_status": job.last_status,
|
||||
"last_error": job.last_error,
|
||||
"created_at": job.created_at.isoformat(),
|
||||
"updated_at": job.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _check_cron_secret() -> bool:
|
||||
expected = os.environ.get("CRON_SECRET", "")
|
||||
if not expected:
|
||||
return False
|
||||
provided = request.headers.get("X-Cron-Secret", "")
|
||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
|
||||
@cron_blueprint.route("/", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_jobs():
|
||||
jobs = await CronJob.all().order_by("name")
|
||||
return jsonify([_serialize(j) for j in jobs])
|
||||
|
||||
|
||||
@cron_blueprint.route("/", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_job():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
prompt = (data.get("prompt") or "").strip()
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
channel_str = data.get("channel")
|
||||
description = (data.get("description") or "").strip() or None
|
||||
|
||||
if not name or not prompt or not recipient or not channel_str:
|
||||
return jsonify(
|
||||
{"error": "name, prompt, recipient, and channel are required"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
channel = MessageChannel(channel_str)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{"error": f"Invalid channel: {channel_str}. Must be 'imessage' or 'email'"}
|
||||
), 400
|
||||
|
||||
job = await CronJob.create(
|
||||
name=name,
|
||||
description=description,
|
||||
prompt=prompt,
|
||||
recipient=recipient,
|
||||
channel=channel,
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
)
|
||||
return jsonify(_serialize(job)), 201
|
||||
|
||||
|
||||
@cron_blueprint.route("/<job_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_job(job_id: str):
|
||||
job = await CronJob.get_or_none(id=job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
if "name" in data:
|
||||
job.name = data["name"].strip()
|
||||
if "description" in data:
|
||||
job.description = (data["description"] or "").strip() or None
|
||||
if "prompt" in data:
|
||||
job.prompt = data["prompt"].strip()
|
||||
if "recipient" in data:
|
||||
job.recipient = data["recipient"].strip()
|
||||
if "channel" in data:
|
||||
try:
|
||||
job.channel = MessageChannel(data["channel"])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid channel: {data['channel']}"}), 400
|
||||
if "enabled" in data:
|
||||
job.enabled = bool(data["enabled"])
|
||||
|
||||
await job.save()
|
||||
return jsonify(_serialize(job))
|
||||
|
||||
|
||||
@cron_blueprint.route("/<job_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def delete_job(job_id: str):
|
||||
job = await CronJob.get_or_none(id=job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
await job.delete()
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
|
||||
@cron_blueprint.route("/<job_id>/trigger", methods=["POST"])
|
||||
async def trigger_job(job_id: str):
|
||||
if not _check_cron_secret():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
job = await CronJob.get_or_none(id=job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if not job.enabled:
|
||||
return jsonify({"skipped": True}), 200
|
||||
|
||||
try:
|
||||
import uuid
|
||||
|
||||
response = await current_app.main_agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": job.prompt}]},
|
||||
config={"configurable": {"thread_id": f"cron-{uuid.uuid4()}"}},
|
||||
)
|
||||
result = response.get("messages", [])[-1].content
|
||||
|
||||
if job.channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(job.recipient, strip_markdown(result))
|
||||
|
||||
elif job.channel == MessageChannel.EMAIL:
|
||||
from blueprints.email import send_email_reply
|
||||
|
||||
await send_email_reply(
|
||||
to=job.recipient,
|
||||
subject=job.name,
|
||||
body=result,
|
||||
)
|
||||
|
||||
job.last_run_at = datetime.now(timezone.utc)
|
||||
job.last_status = "success"
|
||||
job.last_error = None
|
||||
await job.save()
|
||||
logger.info(f"Cron job {job.id} ({job.name}) executed successfully")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
except Exception as e:
|
||||
job.last_run_at = datetime.now(timezone.utc)
|
||||
job.last_status = "failed"
|
||||
job.last_error = str(e)
|
||||
await job.save()
|
||||
logger.error(f"Cron job {job.id} ({job.name}) failed: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -0,0 +1,22 @@
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from blueprints.scheduled_messages.models import MessageChannel
|
||||
|
||||
|
||||
class CronJob(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
description = fields.TextField(null=True)
|
||||
prompt = fields.TextField()
|
||||
recipient = fields.CharField(max_length=255)
|
||||
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
last_run_at = fields.DatetimeField(null=True)
|
||||
last_status = fields.CharField(max_length=50, null=True)
|
||||
last_error = fields.TextField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "cron_jobs"
|
||||
@@ -7,14 +7,15 @@ import time
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, request
|
||||
from quart import Blueprint, current_app, request
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
get_conversation_for_user,
|
||||
get_conversation_for_channel,
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.models import Speaker
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from . import models # noqa: F401 — register Tortoise ORM models
|
||||
from .helpers import generate_email_token, get_user_email_address # noqa: F401
|
||||
@@ -176,7 +177,7 @@ async def webhook():
|
||||
|
||||
# Get or create conversation
|
||||
try:
|
||||
conversation = await get_conversation_for_user(user=user)
|
||||
conversation = await get_conversation_for_channel(user=user, channel="email")
|
||||
await conversation.fetch_related("messages")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||
@@ -193,17 +194,20 @@ async def webhook():
|
||||
# Build messages payload
|
||||
try:
|
||||
messages = await conversation.messages.all()
|
||||
recent_messages = list(messages)[-10:]
|
||||
recent_messages = list(messages)[-20:]
|
||||
|
||||
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||
for msg in recent_messages[:-1]:
|
||||
role = "user" if msg.speaker == "user" else "assistant"
|
||||
role = "user" if msg.speaker == Speaker.USER else "assistant"
|
||||
messages_payload.append({"role": role, "content": msg.text})
|
||||
messages_payload.append({"role": "user", "content": body})
|
||||
|
||||
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
|
||||
response = await main_agent.ainvoke({"messages": messages_payload})
|
||||
response = await current_app.main_agent.ainvoke({"messages": messages_payload})
|
||||
response_text = response.get("messages", [])[-1].content
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error invoking agent for email: {e}")
|
||||
response_text = "Sorry, I'm having trouble thinking right now."
|
||||
|
||||
@@ -4,11 +4,27 @@ Encryption service for email credentials.
|
||||
Provides transparent Fernet encryption for sensitive fields in the database.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from tortoise import fields
|
||||
|
||||
|
||||
def _is_fernet_token(value: str) -> bool:
|
||||
"""Return True if value looks like a Fernet token.
|
||||
|
||||
Fernet tokens are URL-safe base64 and decode to at least 73 bytes
|
||||
(1 version + 8 timestamp + 16 IV + 16 min-ciphertext + 32 HMAC).
|
||||
The first decoded byte is always 0x80 (the Fernet version byte).
|
||||
"""
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded.encode())
|
||||
return len(raw) >= 73 and raw[0] == 0x80
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class EncryptedTextField(fields.TextField):
|
||||
"""
|
||||
Custom Tortoise ORM field that transparently encrypts/decrypts text values.
|
||||
@@ -38,10 +54,16 @@ class EncryptedTextField(fields.TextField):
|
||||
return self.fernet.encrypt(value.encode()).decode()
|
||||
|
||||
def to_python_value(self, value: str) -> str:
|
||||
"""Decrypt value when loading from database."""
|
||||
"""Decrypt value when loading from database.
|
||||
|
||||
Plaintext values (e.g. freshly-set before the first DB round-trip) are
|
||||
returned as-is. Values that look like Fernet tokens are decrypted — a
|
||||
wrong key still raises InvalidToken, preserving the security guarantee.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
# Decrypt Fernet token
|
||||
if not _is_fernet_token(value):
|
||||
return value
|
||||
return self.fernet.decrypt(value.encode()).decode()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import asyncio
|
||||
import os
|
||||
import hmac
|
||||
import logging
|
||||
import functools
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
get_agent_for,
|
||||
get_conversation_for_channel,
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage
|
||||
from blueprints.conversation.memory import build_system_prompt_with_memories
|
||||
from utils.strip_markdown import extract_agent_text, strip_markdown
|
||||
|
||||
imessage_blueprint = Blueprint("imessage_api", __name__, url_prefix="/api/imessage")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limiting: per-number message timestamps
|
||||
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
RATE_LIMIT_MAX = int(os.getenv("IMESSAGE_RATE_LIMIT_MAX", "10"))
|
||||
RATE_LIMIT_WINDOW = int(os.getenv("IMESSAGE_RATE_LIMIT_WINDOW", "60"))
|
||||
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
|
||||
SENDBLUE_API_BASE = "https://api.sendblue.co"
|
||||
|
||||
|
||||
def _get_sendblue_headers() -> dict[str, str]:
|
||||
return {
|
||||
"sb-api-key-id": os.getenv("SENDBLUE_API_KEY", ""),
|
||||
"sb-api-secret-key": os.getenv("SENDBLUE_API_SECRET", ""),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _check_rate_limit(phone_number: str) -> bool:
|
||||
"""Check if a phone number has exceeded the rate limit.
|
||||
|
||||
Returns True if the request is allowed, False if rate-limited.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - RATE_LIMIT_WINDOW
|
||||
|
||||
timestamps = _rate_limit_store[phone_number]
|
||||
_rate_limit_store[phone_number] = [t for t in timestamps if t > cutoff]
|
||||
|
||||
if len(_rate_limit_store[phone_number]) >= RATE_LIMIT_MAX:
|
||||
return False
|
||||
|
||||
_rate_limit_store[phone_number].append(now)
|
||||
return True
|
||||
|
||||
|
||||
async def send_imessage(
|
||||
to_number: str,
|
||||
content: str,
|
||||
reply_to_handle: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an iMessage via SendBlue API."""
|
||||
from_number = os.getenv("SENDBLUE_FROM_NUMBER", "")
|
||||
|
||||
payload: dict = {
|
||||
"number": to_number,
|
||||
"from_number": from_number,
|
||||
"content": content,
|
||||
}
|
||||
if reply_to_handle:
|
||||
payload["reply_to"] = {"message_handle": reply_to_handle}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{SENDBLUE_API_BASE}/api/send-message",
|
||||
headers=_get_sendblue_headers(),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def send_typing_indicator(
|
||||
to_number: str,
|
||||
state: str = "start",
|
||||
max_duration_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Send or stop a typing indicator. Errors are logged and swallowed."""
|
||||
from_number = os.getenv("SENDBLUE_FROM_NUMBER", "")
|
||||
payload: dict = {
|
||||
"number": to_number,
|
||||
"from_number": from_number,
|
||||
"state": state,
|
||||
}
|
||||
if max_duration_ms is not None:
|
||||
payload["max_duration_ms"] = max_duration_ms
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
f"{SENDBLUE_API_BASE}/api/send-typing-indicator",
|
||||
headers=_get_sendblue_headers(),
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send typing indicator ({state}): {e}")
|
||||
|
||||
|
||||
def validate_sendblue_signature(f):
|
||||
"""Decorator to validate the SendBlue webhook signing secret."""
|
||||
|
||||
@functools.wraps(f)
|
||||
async def decorated_function(*args, **kwargs):
|
||||
if os.getenv("SENDBLUE_SIGNATURE_VALIDATION", "true").lower() == "false":
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
secret = os.getenv("SENDBLUE_WEBHOOK_SECRET")
|
||||
if not secret:
|
||||
logger.error("SENDBLUE_WEBHOOK_SECRET not set — rejecting request")
|
||||
return jsonify({"error": "Server misconfigured"}), 500
|
||||
|
||||
sig = request.headers.get("sb-signing-secret", "")
|
||||
if not hmac.compare_digest(sig, secret):
|
||||
logger.warning("Invalid SendBlue signing secret")
|
||||
return jsonify({"error": "Unauthorized"}), 403
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@imessage_blueprint.route("/webhook", methods=["POST"])
|
||||
@validate_sendblue_signature
|
||||
async def webhook():
|
||||
"""Handle incoming iMessages from SendBlue."""
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
from_number = data.get("from_number")
|
||||
content = data.get("content") or ""
|
||||
media_url = data.get("media_url")
|
||||
is_outbound = data.get("is_outbound", False)
|
||||
|
||||
# Threading fields from SendBlue
|
||||
inbound_handle = data.get("message_handle")
|
||||
reply_to_handle = (data.get("reply_to") or {}).get("message_handle")
|
||||
originator_handle = (data.get("thread_originator") or {}).get("message_handle")
|
||||
|
||||
# Ignore outbound messages (our own replies echoed back)
|
||||
if is_outbound:
|
||||
return jsonify({"status": "ignored"}), 200
|
||||
|
||||
if not from_number:
|
||||
return jsonify({"error": "Missing from_number"}), 400
|
||||
|
||||
if not content and not media_url:
|
||||
return jsonify({"error": "Missing content and media_url"}), 400
|
||||
|
||||
content = content.strip()
|
||||
if not content and not media_url:
|
||||
await send_imessage(
|
||||
from_number,
|
||||
"I received an empty message. Please send some text or an image!",
|
||||
)
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
# Rate limiting
|
||||
if not _check_rate_limit(from_number):
|
||||
logger.warning(f"Rate limit exceeded for {from_number}")
|
||||
await send_imessage(
|
||||
from_number,
|
||||
"You're sending messages too quickly. Please wait a moment and try again.",
|
||||
)
|
||||
return jsonify({"status": "rate_limited"}), 200
|
||||
|
||||
# Truncate overly long messages
|
||||
if len(content) > MAX_MESSAGE_LENGTH:
|
||||
content = content[:MAX_MESSAGE_LENGTH]
|
||||
logger.info(
|
||||
f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars"
|
||||
)
|
||||
|
||||
logger.info(f"Received iMessage from {from_number}: {content[:100]}")
|
||||
|
||||
# Identify or create user
|
||||
user = await User.filter(imessage_number=from_number).first()
|
||||
|
||||
if not user:
|
||||
allowed_numbers = os.getenv("ALLOWED_IMESSAGE_NUMBERS", "").split(",")
|
||||
if from_number not in allowed_numbers and "*" not in allowed_numbers:
|
||||
await send_imessage(
|
||||
from_number, "Sorry, you are not authorized to use this service."
|
||||
)
|
||||
return jsonify({"status": "unauthorized"}), 200
|
||||
|
||||
username = f"im_{from_number.lstrip('+')}"
|
||||
try:
|
||||
user = await User.create(
|
||||
username=username,
|
||||
email=f"{username}@imessage.simbarag.local",
|
||||
imessage_number=from_number,
|
||||
auth_provider="imessage",
|
||||
)
|
||||
logger.info(f"Created new user for iMessage: {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create user for {from_number}: {e}")
|
||||
await send_imessage(
|
||||
from_number, "Sorry, something went wrong setting up your account."
|
||||
)
|
||||
return jsonify({"status": "error"}), 200
|
||||
|
||||
# iMessage is restricted to admins
|
||||
if not user.is_admin():
|
||||
logger.warning(f"Non-admin user {user.username} attempted iMessage access")
|
||||
await send_imessage(from_number, "Sorry, this feature is restricted to admins.")
|
||||
return jsonify({"status": "forbidden"}), 200
|
||||
|
||||
# /threads command — list recent iMessage conversations
|
||||
if content.strip().lower() == "/threads":
|
||||
threads = (
|
||||
await Conversation.filter(user=user, channel="imessage")
|
||||
.order_by("-updated_at")
|
||||
.limit(5)
|
||||
)
|
||||
if not threads:
|
||||
await send_imessage(from_number, "No threads found.")
|
||||
else:
|
||||
lines = ["Your recent threads (most recent first):"]
|
||||
for i, t in enumerate(threads, 1):
|
||||
updated = t.updated_at.strftime("%b %-d")
|
||||
lines.append(f"{i}. {t.name} ({updated})")
|
||||
lines.append("\nReply to any message to continue its thread.")
|
||||
await send_imessage(from_number, "\n".join(lines))
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
# Determine conversation based on iMessage reply threading:
|
||||
# - Reply to an existing message → continue that conversation
|
||||
# - New standalone message → start a fresh conversation
|
||||
try:
|
||||
if reply_to_handle:
|
||||
lookup_handle = originator_handle or reply_to_handle
|
||||
parent_msg = await ConversationMessage.get_or_none(
|
||||
message_handle=lookup_handle,
|
||||
conversation__user=user,
|
||||
conversation__channel="imessage",
|
||||
)
|
||||
if parent_msg:
|
||||
conversation = await Conversation.get(id=parent_msg.conversation_id)
|
||||
logger.info(
|
||||
f"Continuing conversation {conversation.id} via reply to {lookup_handle!r}"
|
||||
)
|
||||
else:
|
||||
# Handle not found (pre-migration messages) — fall back to most recent
|
||||
logger.warning(
|
||||
f"Reply handle {lookup_handle!r} not in DB; falling back to most recent conversation"
|
||||
)
|
||||
conversation = await get_conversation_for_channel(
|
||||
user=user, channel="imessage"
|
||||
)
|
||||
else:
|
||||
conversation = await Conversation.create(
|
||||
name="New iMessage",
|
||||
user=user,
|
||||
channel="imessage",
|
||||
)
|
||||
logger.info(
|
||||
f"New iMessage conversation {conversation.id} for {user.username}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get/create conversation for {user.username}: {e}")
|
||||
await send_imessage(
|
||||
from_number, "Sorry, something went wrong. Please try again later."
|
||||
)
|
||||
return jsonify({"status": "error"}), 200
|
||||
|
||||
# Save the inbound message now so it's persisted before we ack
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=content,
|
||||
speaker="user",
|
||||
user=user,
|
||||
message_handle=inbound_handle,
|
||||
)
|
||||
|
||||
# Fire LLM processing in the background so we can ack SendBlue immediately.
|
||||
# SendBlue retries if it doesn't receive a 200 within its timeout (~5s).
|
||||
asyncio.ensure_future(
|
||||
_process_and_reply(
|
||||
from_number=from_number,
|
||||
inbound_handle=inbound_handle,
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
content=content,
|
||||
media_url=media_url,
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
async def _process_and_reply(
|
||||
from_number: str,
|
||||
inbound_handle: str | None,
|
||||
conversation: Conversation,
|
||||
user,
|
||||
content: str,
|
||||
media_url: str | None,
|
||||
) -> None:
|
||||
"""Run the LLM agent and send the reply. Called as a background task."""
|
||||
# Attach image if present
|
||||
image_block = None
|
||||
if media_url:
|
||||
try:
|
||||
from utils.image_process import image_content_block
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(media_url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
image_block = image_content_block(resp.content)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load iMessage image: {e}")
|
||||
content = f"{content or ''}\n\n[An image was sent but could not be loaded.]".strip()
|
||||
|
||||
await send_typing_indicator(from_number, state="start", max_duration_ms=120_000)
|
||||
|
||||
response_text = "Sorry, I'm having trouble thinking right now."
|
||||
try:
|
||||
if image_block:
|
||||
user_content = [
|
||||
{"type": "text", "text": content or "The user sent this image."},
|
||||
image_block,
|
||||
]
|
||||
else:
|
||||
user_content = content
|
||||
|
||||
system_prompt = await build_system_prompt_with_memories(str(user.id))
|
||||
system_prompt += (
|
||||
"\n\nIMPORTANT: You are replying via iMessage. Use plain text only. "
|
||||
"Do not use markdown tables, headers, bullet points, bold, italics, "
|
||||
"or any other markdown formatting. Write in natural prose."
|
||||
)
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
logger.info(f"Invoking LangChain agent for conversation {conversation.id}")
|
||||
response = await get_agent_for(user).ainvoke(
|
||||
{"messages": [{"role": "user", "content": user_content}]},
|
||||
config=agent_config,
|
||||
)
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error invoking agent: {e}")
|
||||
finally:
|
||||
await send_typing_indicator(from_number, state="stop")
|
||||
|
||||
send_resp = await send_imessage(
|
||||
from_number,
|
||||
strip_markdown(response_text),
|
||||
reply_to_handle=inbound_handle,
|
||||
)
|
||||
outbound_handle = (send_resp or {}).get("message_handle")
|
||||
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=response_text,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
message_handle=outbound_handle,
|
||||
)
|
||||
@@ -1,7 +1,12 @@
|
||||
from quart import Blueprint, jsonify
|
||||
from quart_jwt_extended import jwt_refresh_token_required
|
||||
|
||||
from .logic import fetch_obsidian_documents, get_vector_store_stats, index_documents, index_obsidian_documents, vector_store
|
||||
from .logic import (
|
||||
delete_all_documents,
|
||||
get_vector_store_stats,
|
||||
index_documents,
|
||||
sync_obsidian_documents,
|
||||
)
|
||||
from blueprints.users.decorators import admin_required
|
||||
|
||||
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
|
||||
@@ -32,14 +37,7 @@ async def trigger_index():
|
||||
async def trigger_reindex():
|
||||
"""Clear and reindex all documents. Admin only."""
|
||||
try:
|
||||
# Clear existing documents
|
||||
collection = vector_store._collection
|
||||
all_docs = collection.get()
|
||||
|
||||
if all_docs["ids"]:
|
||||
collection.delete(ids=all_docs["ids"])
|
||||
|
||||
# Reindex
|
||||
delete_all_documents()
|
||||
await index_documents()
|
||||
stats = get_vector_store_stats()
|
||||
return jsonify({"status": "success", "stats": stats})
|
||||
@@ -50,9 +48,9 @@ async def trigger_reindex():
|
||||
@rag_blueprint.post("/index-obsidian")
|
||||
@admin_required
|
||||
async def trigger_obsidian_index():
|
||||
"""Index all Obsidian markdown documents into vector store. Admin only."""
|
||||
"""Incrementally sync Obsidian documents into vector store. Admin only."""
|
||||
try:
|
||||
result = await index_obsidian_documents()
|
||||
result = await sync_obsidian_documents()
|
||||
stats = get_vector_store_stats()
|
||||
return jsonify({"status": "success", "result": result, "stats": stats})
|
||||
except Exception as e:
|
||||
|
||||
+384
-42
@@ -1,11 +1,19 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_core.documents import Document
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from langchain_postgres import PGVector
|
||||
from langchain_text_splitters import (
|
||||
MarkdownHeaderTextSplitter,
|
||||
RecursiveCharacterTextSplitter,
|
||||
)
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from .fetchers import PaperlessNGXService
|
||||
from utils.obsidian_service import ObsidianService
|
||||
@@ -13,13 +21,51 @@ from utils.obsidian_service import ObsidianService
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
vector_store = Chroma(
|
||||
collection_name="simba_docs",
|
||||
embedding_function=embeddings,
|
||||
persist_directory=os.getenv("CHROMADB_PATH", ""),
|
||||
_embedding_server_url = os.getenv("EMBEDDING_SERVER_URL")
|
||||
_embedding_model = os.getenv("EMBEDDING_MODEL_NAME", "text-embedding-3-small")
|
||||
|
||||
if _embedding_server_url:
|
||||
embeddings = OpenAIEmbeddings(
|
||||
model=_embedding_model,
|
||||
base_url=_embedding_server_url,
|
||||
api_key="not-needed",
|
||||
check_embedding_ctx_length=False,
|
||||
)
|
||||
else:
|
||||
embeddings = OpenAIEmbeddings(model=_embedding_model)
|
||||
|
||||
# Convert Tortoise-style postgres:// URL to SQLAlchemy-style postgresql+psycopg://
|
||||
_db_url = os.getenv(
|
||||
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||
)
|
||||
_pgvector_url = _db_url.replace("postgres://", "postgresql+psycopg://")
|
||||
|
||||
# Lazy-initialized vector store (defers DB connection to first use)
|
||||
_vector_store = None
|
||||
|
||||
|
||||
def _get_vector_store() -> PGVector:
|
||||
global _vector_store
|
||||
if _vector_store is None:
|
||||
_vector_store = PGVector(
|
||||
embeddings=embeddings,
|
||||
collection_name="simba_docs",
|
||||
connection=_pgvector_url,
|
||||
use_jsonb=True,
|
||||
create_extension=False, # created by docker init script
|
||||
async_mode=True,
|
||||
)
|
||||
return _vector_store
|
||||
|
||||
|
||||
def _get_engine():
|
||||
"""Get a SQLAlchemy engine for direct queries."""
|
||||
if not hasattr(_get_engine, "_engine"):
|
||||
_get_engine._engine = create_engine(_pgvector_url)
|
||||
return _get_engine._engine
|
||||
|
||||
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=1000, # chunk size (characters)
|
||||
@@ -27,6 +73,62 @@ text_splitter = RecursiveCharacterTextSplitter(
|
||||
add_start_index=True, # track index in original document
|
||||
)
|
||||
|
||||
md_header_splitter = MarkdownHeaderTextSplitter(
|
||||
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
|
||||
strip_headers=False,
|
||||
)
|
||||
|
||||
md_chunk_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=1000,
|
||||
chunk_overlap=200,
|
||||
add_start_index=True,
|
||||
)
|
||||
|
||||
|
||||
def _split_markdown_document(doc: Document) -> list[Document]:
|
||||
"""Split a markdown document by headers first, then by size.
|
||||
|
||||
Prepends the note filename to each chunk so chunks are self-contained.
|
||||
"""
|
||||
note_name = (
|
||||
Path(doc.metadata.get("filepath", "")).stem
|
||||
if doc.metadata.get("filepath")
|
||||
else ""
|
||||
)
|
||||
|
||||
# Split by markdown headers
|
||||
header_splits = md_header_splitter.split_text(doc.page_content)
|
||||
|
||||
# Carry over original document metadata to each header split
|
||||
for split in header_splits:
|
||||
split.metadata.update(doc.metadata)
|
||||
|
||||
# Then apply size-based splitting on large sections
|
||||
sized_splits = md_chunk_splitter.split_documents(header_splits)
|
||||
|
||||
# Prepend note name for self-contained context
|
||||
if note_name:
|
||||
for split in sized_splits:
|
||||
split.page_content = f"[Note: {note_name}]\n{split.page_content}"
|
||||
|
||||
return sized_splits
|
||||
|
||||
|
||||
def _get_collection_id():
|
||||
"""Get the UUID of our collection from the langchain_pg_collection table."""
|
||||
engine = _get_engine()
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text("SELECT uuid FROM langchain_pg_collection WHERE name = :name"),
|
||||
{"name": "simba_docs"},
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
except Exception:
|
||||
# Table doesn't exist yet (first run before any indexing)
|
||||
return None
|
||||
|
||||
|
||||
def date_to_epoch(date_str: str) -> float:
|
||||
split_date = date_str.split("-")
|
||||
@@ -49,6 +151,7 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
|
||||
documents = []
|
||||
for doc in data:
|
||||
metadata = {
|
||||
"source": "paperless",
|
||||
"created_date": date_to_epoch(doc["created_date"]),
|
||||
"filename": doc["original_file_name"],
|
||||
"document_type": doctypes.get(doc["document_type"], ""),
|
||||
@@ -58,12 +161,54 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
|
||||
return documents
|
||||
|
||||
|
||||
def _make_serializable(value):
|
||||
"""Convert a value to a JSON-serializable type."""
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_make_serializable(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _make_serializable(v) for k, v in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _sanitize_text(text_content: str) -> str:
|
||||
"""Strip non-printable and invalid characters that break embedding tokenizers."""
|
||||
# Remove null bytes and control characters (keep newlines and tabs)
|
||||
text_content = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", text_content)
|
||||
# Remove Unicode surrogates and other problematic Unicode
|
||||
text_content = re.sub(r"[\ud800-\udfff\ufffe\uffff]", "", text_content)
|
||||
# Remove replacement character clusters
|
||||
text_content = text_content.replace("\ufffd", "")
|
||||
# Collapse excessive whitespace
|
||||
text_content = re.sub(r" {3,}", " ", text_content)
|
||||
return text_content.strip()
|
||||
|
||||
|
||||
def _sanitize_documents(documents: list[Document]) -> list[Document]:
|
||||
"""Sanitize page_content of all documents for embedding compatibility."""
|
||||
for doc in documents:
|
||||
doc.page_content = _sanitize_text(doc.page_content)
|
||||
return [doc for doc in documents if doc.page_content]
|
||||
|
||||
|
||||
async def index_documents():
|
||||
"""Index Paperless-NGX documents into vector store."""
|
||||
documents = await fetch_documents_from_paperless_ngx()
|
||||
|
||||
splits = text_splitter.split_documents(documents)
|
||||
await vector_store.aadd_documents(documents=splits)
|
||||
splits = _sanitize_documents(splits)
|
||||
logger.info(f"Indexing {len(splits)} chunks from {len(documents)} documents")
|
||||
vector_store = _get_vector_store()
|
||||
for i, split in enumerate(splits):
|
||||
try:
|
||||
await vector_store.aadd_documents(documents=[split])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to embed chunk {i} from {split.metadata.get('filename', 'unknown')}: {e}"
|
||||
)
|
||||
logger.debug(f"Chunk content preview: {split.page_content[:200]!r}")
|
||||
raise
|
||||
|
||||
|
||||
async def fetch_obsidian_documents() -> list[Document]:
|
||||
@@ -85,20 +230,29 @@ async def fetch_obsidian_documents() -> list[Document]:
|
||||
parsed = obsidian_service.parse_markdown(content, md_path)
|
||||
|
||||
# Create LangChain Document with obsidian source
|
||||
metadata = {
|
||||
"source": "obsidian",
|
||||
"filepath": parsed["filepath"],
|
||||
"folder": str(Path(parsed["filepath"]).parent)
|
||||
if parsed["filepath"]
|
||||
else "",
|
||||
"tags": parsed["tags"],
|
||||
"created_at": parsed["metadata"].get("created_at"),
|
||||
"indexed_at": time.time(),
|
||||
**{
|
||||
k: v
|
||||
for k, v in parsed["metadata"].items()
|
||||
if k not in ["created_at", "created_by"]
|
||||
},
|
||||
}
|
||||
document = Document(
|
||||
page_content=parsed["content"],
|
||||
metadata={
|
||||
"source": "obsidian",
|
||||
"filepath": parsed["filepath"],
|
||||
"tags": parsed["tags"],
|
||||
"created_at": parsed["metadata"].get("created_at"),
|
||||
**{k: v for k, v in parsed["metadata"].items() if k not in ["created_at", "created_by"]},
|
||||
},
|
||||
metadata=_make_serializable(metadata),
|
||||
)
|
||||
documents.append(document)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading {md_path}: {e}")
|
||||
logger.warning(f"Error reading {md_path}: {e}")
|
||||
continue
|
||||
|
||||
return documents
|
||||
@@ -109,27 +263,168 @@ async def index_obsidian_documents():
|
||||
|
||||
Deletes existing obsidian source chunks before re-indexing.
|
||||
"""
|
||||
obsidian_service = ObsidianService()
|
||||
documents = await fetch_obsidian_documents()
|
||||
|
||||
if not documents:
|
||||
print("No Obsidian documents found to index")
|
||||
logger.info("No Obsidian documents found to index")
|
||||
return {"indexed": 0}
|
||||
|
||||
# Delete existing obsidian chunks
|
||||
existing_results = vector_store.get(where={"source": "obsidian"})
|
||||
if existing_results.get("ids"):
|
||||
await vector_store.adelete(existing_results["ids"])
|
||||
delete_documents_by_metadata("source", "obsidian")
|
||||
|
||||
# Split and index documents
|
||||
splits = text_splitter.split_documents(documents)
|
||||
# Split using markdown-aware chunking, sanitize, and index
|
||||
splits = []
|
||||
for doc in documents:
|
||||
splits.extend(_split_markdown_document(doc))
|
||||
splits = _sanitize_documents(splits)
|
||||
vector_store = _get_vector_store()
|
||||
await vector_store.aadd_documents(documents=splits)
|
||||
|
||||
return {"indexed": len(documents)}
|
||||
|
||||
|
||||
async def query_vector_store(query: str):
|
||||
retrieved_docs = await vector_store.asimilarity_search(query, k=2)
|
||||
# In-memory cache of indexed obsidian files: {filepath: indexed_at}
|
||||
_obsidian_index_cache: dict[str, float] = {}
|
||||
|
||||
|
||||
def _load_obsidian_index_cache() -> dict[str, float]:
|
||||
"""Load indexed obsidian files from DB into cache (cold start only)."""
|
||||
collection_id = _get_collection_id()
|
||||
if not collection_id:
|
||||
return {}
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT DISTINCT cmetadata->>'filepath' AS filepath, "
|
||||
"MAX((cmetadata->>'indexed_at')::float) AS indexed_at "
|
||||
"FROM langchain_pg_embedding "
|
||||
"WHERE collection_id = :cid AND cmetadata->>'source' = 'obsidian' "
|
||||
"GROUP BY cmetadata->>'filepath'"
|
||||
),
|
||||
{"cid": collection_id},
|
||||
)
|
||||
return {row[0]: row[1] for row in result if row[0] is not None}
|
||||
|
||||
|
||||
async def sync_obsidian_documents() -> dict[str, int]:
|
||||
"""Incrementally sync Obsidian documents to pgvector.
|
||||
|
||||
Compares file mtimes against stored indexed_at timestamps to only
|
||||
re-index changed/new files and remove deleted ones.
|
||||
|
||||
Returns:
|
||||
Dict with counts of added, updated, and deleted files.
|
||||
"""
|
||||
global _obsidian_index_cache
|
||||
|
||||
obsidian_service = ObsidianService()
|
||||
|
||||
# Load cache from DB on first run
|
||||
if not _obsidian_index_cache:
|
||||
_obsidian_index_cache = _load_obsidian_index_cache()
|
||||
|
||||
# Build map of current vault files -> mtime
|
||||
vault_files: dict[str, float] = {}
|
||||
for md_path in obsidian_service.walk_vault():
|
||||
vault_files[str(md_path)] = md_path.stat().st_mtime
|
||||
|
||||
added = 0
|
||||
updated = 0
|
||||
deleted = 0
|
||||
|
||||
# Find files to add or update
|
||||
files_to_index: list[str] = []
|
||||
for filepath, mtime in vault_files.items():
|
||||
indexed_at = _obsidian_index_cache.get(filepath)
|
||||
if indexed_at is None:
|
||||
files_to_index.append(filepath)
|
||||
added += 1
|
||||
elif mtime > indexed_at:
|
||||
# Delete old chunks first
|
||||
delete_documents_by_metadata("filepath", filepath)
|
||||
files_to_index.append(filepath)
|
||||
updated += 1
|
||||
|
||||
# Find deleted files (in cache but not on disk)
|
||||
for filepath in list(_obsidian_index_cache):
|
||||
if filepath not in vault_files:
|
||||
delete_documents_by_metadata("filepath", filepath)
|
||||
del _obsidian_index_cache[filepath]
|
||||
deleted += 1
|
||||
|
||||
# Index new/changed files
|
||||
if files_to_index:
|
||||
now = time.time()
|
||||
documents = []
|
||||
for filepath in files_to_index:
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
parsed = obsidian_service.parse_markdown(content, filepath)
|
||||
metadata = {
|
||||
"source": "obsidian",
|
||||
"filepath": parsed["filepath"],
|
||||
"folder": str(Path(parsed["filepath"]).parent)
|
||||
if parsed["filepath"]
|
||||
else "",
|
||||
"tags": parsed["tags"],
|
||||
"created_at": parsed["metadata"].get("created_at"),
|
||||
"indexed_at": now,
|
||||
**{
|
||||
k: v
|
||||
for k, v in parsed["metadata"].items()
|
||||
if k not in ["created_at", "created_by"]
|
||||
},
|
||||
}
|
||||
document = Document(
|
||||
page_content=parsed["content"],
|
||||
metadata=_make_serializable(metadata),
|
||||
)
|
||||
documents.append(document)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading {filepath}: {e}")
|
||||
continue
|
||||
|
||||
if documents:
|
||||
splits = []
|
||||
for doc in documents:
|
||||
splits.extend(_split_markdown_document(doc))
|
||||
splits = _sanitize_documents(splits)
|
||||
if splits:
|
||||
vector_store = _get_vector_store()
|
||||
await vector_store.aadd_documents(documents=splits)
|
||||
|
||||
# Update cache for successfully processed files
|
||||
for filepath in files_to_index:
|
||||
_obsidian_index_cache[filepath] = now
|
||||
|
||||
logger.info(
|
||||
f"Obsidian sync complete: {added} added, {updated} updated, {deleted} deleted"
|
||||
)
|
||||
return {"added": added, "updated": updated, "deleted": deleted}
|
||||
|
||||
|
||||
async def query_vector_store(
|
||||
query: str,
|
||||
source: str | None = None,
|
||||
k: int = 8,
|
||||
):
|
||||
"""Query the vector store with optional source filtering and MMR.
|
||||
|
||||
Args:
|
||||
query: Search query text
|
||||
source: Filter by source metadata (e.g., "obsidian", "paperless")
|
||||
k: Number of results to return
|
||||
"""
|
||||
vector_store = _get_vector_store()
|
||||
filter_dict = {"source": source} if source else None
|
||||
retrieved_docs = await vector_store.amax_marginal_relevance_search(
|
||||
query,
|
||||
k=k,
|
||||
fetch_k=k * 3,
|
||||
filter=filter_dict,
|
||||
)
|
||||
serialized = "\n\n".join(
|
||||
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
|
||||
for doc in retrieved_docs
|
||||
@@ -137,33 +432,80 @@ async def query_vector_store(query: str):
|
||||
return serialized, retrieved_docs
|
||||
|
||||
|
||||
def delete_all_documents():
|
||||
"""Delete all documents from the vector store collection."""
|
||||
collection_id = _get_collection_id()
|
||||
if not collection_id:
|
||||
return
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("DELETE FROM langchain_pg_embedding WHERE collection_id = :cid"),
|
||||
{"cid": collection_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_documents_by_metadata(key: str, value: str):
|
||||
"""Delete documents matching a metadata key/value pair."""
|
||||
collection_id = _get_collection_id()
|
||||
if not collection_id:
|
||||
return
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"DELETE FROM langchain_pg_embedding "
|
||||
"WHERE collection_id = :cid AND cmetadata->>:key = :value"
|
||||
),
|
||||
{"cid": collection_id, "key": key, "value": value},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_vector_store_stats():
|
||||
"""Get statistics about the vector store."""
|
||||
collection = vector_store._collection
|
||||
count = collection.count()
|
||||
collection_id = _get_collection_id()
|
||||
count = 0
|
||||
if collection_id:
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM langchain_pg_embedding WHERE collection_id = :cid"
|
||||
),
|
||||
{"cid": collection_id},
|
||||
)
|
||||
count = result.scalar()
|
||||
return {
|
||||
"total_documents": count,
|
||||
"collection_name": collection.name,
|
||||
"collection_name": "simba_docs",
|
||||
}
|
||||
|
||||
|
||||
def list_all_documents(limit: int = 10):
|
||||
"""List documents in the vector store with their metadata."""
|
||||
collection = vector_store._collection
|
||||
results = collection.get(limit=limit, include=["metadatas", "documents"])
|
||||
collection_id = _get_collection_id()
|
||||
if not collection_id:
|
||||
return []
|
||||
|
||||
documents = []
|
||||
for i, doc_id in enumerate(results["ids"]):
|
||||
documents.append(
|
||||
{
|
||||
"id": doc_id,
|
||||
"metadata": results["metadatas"][i]
|
||||
if results.get("metadatas")
|
||||
else None,
|
||||
"content_preview": results["documents"][i][:200]
|
||||
if results.get("documents")
|
||||
else None,
|
||||
}
|
||||
engine = _get_engine()
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
text(
|
||||
"SELECT id, document, cmetadata FROM langchain_pg_embedding "
|
||||
"WHERE collection_id = :cid LIMIT :limit"
|
||||
),
|
||||
{"cid": collection_id, "limit": limit},
|
||||
)
|
||||
documents = []
|
||||
for row in result:
|
||||
documents.append(
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"metadata": row[2],
|
||||
"content_preview": row[1][:200] if row[1] else None,
|
||||
}
|
||||
)
|
||||
|
||||
return documents
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
|
||||
scheduled_messages_blueprint = Blueprint(
|
||||
"scheduled_messages_api", __name__, url_prefix="/api/scheduled-messages"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize(msg: ScheduledMessage) -> dict:
|
||||
return {
|
||||
"id": str(msg.id),
|
||||
"recipient": msg.recipient,
|
||||
"channel": msg.channel.value,
|
||||
"content": msg.content,
|
||||
"subject": msg.subject,
|
||||
"scheduled_at": msg.scheduled_at.isoformat(),
|
||||
"status": msg.status.value,
|
||||
"recurrence": msg.recurrence.value,
|
||||
"use_agent": msg.use_agent,
|
||||
"error_message": msg.error_message,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"updated_at": msg.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_messages():
|
||||
messages = await ScheduledMessage.all().order_by("-scheduled_at")
|
||||
return jsonify([_serialize(m) for m in messages])
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_message():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
channel = data.get("channel")
|
||||
content = (data.get("content") or "").strip()
|
||||
subject = (data.get("subject") or "").strip() or None
|
||||
scheduled_at_str = data.get("scheduled_at")
|
||||
|
||||
recurrence_str = data.get("recurrence", "none")
|
||||
|
||||
if not recipient or not channel or not content or not scheduled_at_str:
|
||||
return jsonify(
|
||||
{"error": "recipient, channel, content, and scheduled_at are required"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
channel_enum = MessageChannel(channel)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{"error": f"Invalid channel: {channel}. Must be 'imessage' or 'email'"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
recurrence_enum = Recurrence(recurrence_str)
|
||||
except ValueError:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Invalid recurrence: {recurrence_str}. Must be 'none', 'daily', 'weekly', or 'monthly'"
|
||||
}
|
||||
), 400
|
||||
|
||||
if channel_enum == MessageChannel.EMAIL and not subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(scheduled_at_str)
|
||||
if scheduled_at.tzinfo is None:
|
||||
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||
|
||||
if scheduled_at <= datetime.now(timezone.utc):
|
||||
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
use_agent = bool(data.get("use_agent", False))
|
||||
|
||||
msg = await ScheduledMessage.create(
|
||||
recipient=recipient,
|
||||
channel=channel_enum,
|
||||
content=content,
|
||||
subject=subject,
|
||||
scheduled_at=scheduled_at,
|
||||
recurrence=recurrence_enum,
|
||||
use_agent=use_agent,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
from utils.cronicle import create_message_event
|
||||
|
||||
await create_message_event(str(msg.id), msg.scheduled_at, recurrence_enum.value)
|
||||
|
||||
return jsonify(_serialize(msg)), 201
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if msg.status not in (MessageStatus.PENDING, MessageStatus.FAILED):
|
||||
return jsonify({"error": "Can only update pending or failed messages"}), 400
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
rescheduled = False
|
||||
if "recipient" in data:
|
||||
msg.recipient = data["recipient"].strip()
|
||||
if "channel" in data:
|
||||
try:
|
||||
msg.channel = MessageChannel(data["channel"])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid channel: {data['channel']}"}), 400
|
||||
if "content" in data:
|
||||
msg.content = data["content"].strip()
|
||||
if "subject" in data:
|
||||
msg.subject = data["subject"].strip() or None
|
||||
if "recurrence" in data:
|
||||
try:
|
||||
msg.recurrence = Recurrence(data["recurrence"])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid recurrence: {data['recurrence']}"}), 400
|
||||
if "use_agent" in data:
|
||||
msg.use_agent = bool(data["use_agent"])
|
||||
if "scheduled_at" in data:
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(data["scheduled_at"])
|
||||
if scheduled_at.tzinfo is None:
|
||||
scheduled_at = scheduled_at.replace(tzinfo=timezone.utc)
|
||||
if scheduled_at <= datetime.now(timezone.utc):
|
||||
return jsonify({"error": "scheduled_at must be in the future"}), 400
|
||||
msg.scheduled_at = scheduled_at
|
||||
rescheduled = True
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||
if "status" in data and data["status"] == "cancelled":
|
||||
msg.status = MessageStatus.CANCELLED
|
||||
elif rescheduled:
|
||||
msg.status = MessageStatus.PENDING
|
||||
msg.error_message = None
|
||||
|
||||
if msg.channel == MessageChannel.EMAIL and not msg.subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
await msg.save()
|
||||
|
||||
if rescheduled and msg.status == MessageStatus.PENDING:
|
||||
from utils.cronicle import create_message_event
|
||||
|
||||
await create_message_event(str(msg.id), msg.scheduled_at, msg.recurrence.value)
|
||||
|
||||
return jsonify(_serialize(msg))
|
||||
|
||||
|
||||
def _check_cron_secret() -> bool:
|
||||
expected = os.environ.get("CRON_SECRET", "")
|
||||
if not expected:
|
||||
return False
|
||||
provided = request.headers.get("X-Cron-Secret", "")
|
||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/trigger", methods=["POST"])
|
||||
async def trigger_due_messages():
|
||||
if not _check_cron_secret():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
from .scheduler import process_due_messages
|
||||
|
||||
result = await process_due_messages()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def delete_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if msg.status not in (MessageStatus.PENDING, MessageStatus.CANCELLED):
|
||||
return jsonify({"error": "Can only delete pending or cancelled messages"}), 400
|
||||
|
||||
await msg.delete()
|
||||
return jsonify({"status": "deleted"})
|
||||
@@ -0,0 +1,48 @@
|
||||
import enum
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class MessageChannel(enum.Enum):
|
||||
IMESSAGE = "imessage"
|
||||
EMAIL = "email"
|
||||
|
||||
|
||||
class MessageStatus(enum.Enum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Recurrence(enum.Enum):
|
||||
NONE = "none"
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
class ScheduledMessage(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
recipient = fields.CharField(max_length=255)
|
||||
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20)
|
||||
content = fields.TextField()
|
||||
subject = fields.CharField(max_length=255, null=True)
|
||||
scheduled_at = fields.DatetimeField()
|
||||
status = fields.CharEnumField(
|
||||
enum_type=MessageStatus, max_length=20, default=MessageStatus.PENDING
|
||||
)
|
||||
recurrence = fields.CharEnumField(
|
||||
enum_type=Recurrence, max_length=20, default=Recurrence.NONE
|
||||
)
|
||||
use_agent = fields.BooleanField(default=False)
|
||||
error_message = fields.TextField(null=True)
|
||||
created_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="scheduled_messages"
|
||||
)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "scheduled_messages"
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from quart import current_app
|
||||
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECURRENCE_DELTAS = {
|
||||
Recurrence.DAILY: relativedelta(days=1),
|
||||
Recurrence.WEEKLY: relativedelta(weeks=1),
|
||||
Recurrence.MONTHLY: relativedelta(months=1),
|
||||
}
|
||||
|
||||
|
||||
async def _run_agent(prompt: str, thread_id: str) -> str:
|
||||
response = await current_app.main_agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": prompt}]},
|
||||
config={"configurable": {"thread_id": thread_id}},
|
||||
)
|
||||
return response.get("messages", [])[-1].content
|
||||
|
||||
|
||||
async def _schedule_next_occurrence(msg: ScheduledMessage):
|
||||
delta = RECURRENCE_DELTAS.get(msg.recurrence)
|
||||
if not delta:
|
||||
return
|
||||
|
||||
next_at = msg.scheduled_at + delta
|
||||
now = datetime.now(timezone.utc)
|
||||
while next_at <= now:
|
||||
next_at += delta
|
||||
|
||||
await ScheduledMessage.create(
|
||||
recipient=msg.recipient,
|
||||
channel=msg.channel,
|
||||
content=msg.content,
|
||||
subject=msg.subject,
|
||||
scheduled_at=next_at,
|
||||
recurrence=msg.recurrence,
|
||||
use_agent=msg.use_agent,
|
||||
created_by_id=msg.created_by_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}"
|
||||
)
|
||||
|
||||
|
||||
async def process_due_messages() -> dict:
|
||||
"""Process all currently due pending messages. Returns a result summary."""
|
||||
now = datetime.now(timezone.utc)
|
||||
due = await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING,
|
||||
scheduled_at__lte=now,
|
||||
).all()
|
||||
|
||||
sent, failed = 0, 0
|
||||
for msg in due:
|
||||
try:
|
||||
send_content = msg.content
|
||||
|
||||
if msg.use_agent:
|
||||
send_content = await _run_agent(
|
||||
msg.content, f"scheduled-{uuid.uuid4()}"
|
||||
)
|
||||
|
||||
if msg.channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(msg.recipient, strip_markdown(send_content))
|
||||
|
||||
elif msg.channel == MessageChannel.EMAIL:
|
||||
from blueprints.email import send_email_reply
|
||||
|
||||
await send_email_reply(
|
||||
to=msg.recipient,
|
||||
subject=msg.subject or "(no subject)",
|
||||
body=send_content,
|
||||
)
|
||||
|
||||
msg.status = MessageStatus.SENT
|
||||
msg.error_message = None
|
||||
await msg.save()
|
||||
logger.info(
|
||||
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
|
||||
)
|
||||
sent += 1
|
||||
|
||||
if msg.recurrence != Recurrence.NONE:
|
||||
await _schedule_next_occurrence(msg)
|
||||
|
||||
except Exception as e:
|
||||
msg.status = MessageStatus.FAILED
|
||||
msg.error_message = str(e)
|
||||
await msg.save()
|
||||
logger.error(f"Failed to send scheduled message {msg.id}: {e}")
|
||||
failed += 1
|
||||
|
||||
return {"sent": sent, "failed": failed}
|
||||
@@ -212,32 +212,42 @@ async def me():
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
return jsonify({
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_admin": user.is_admin(),
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_admin": user.is_admin(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_users():
|
||||
from blueprints.email.helpers import get_user_email_address
|
||||
|
||||
users = await User.all().order_by("username")
|
||||
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
|
||||
return jsonify([
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"whatsapp_number": u.whatsapp_number,
|
||||
"auth_provider": u.auth_provider,
|
||||
"email_enabled": u.email_enabled,
|
||||
"email_address": get_user_email_address(u.email_hmac_token, mailgun_domain) if u.email_hmac_token and u.email_enabled else None,
|
||||
}
|
||||
for u in users
|
||||
])
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"whatsapp_number": u.whatsapp_number,
|
||||
"imessage_number": u.imessage_number,
|
||||
"auth_provider": u.auth_provider,
|
||||
"email_enabled": u.email_enabled,
|
||||
"email_address": get_user_email_address(
|
||||
u.email_hmac_token, mailgun_domain
|
||||
)
|
||||
if u.email_hmac_token and u.email_enabled
|
||||
else None,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["PUT"])
|
||||
@@ -254,17 +264,21 @@ async def set_whatsapp(user_id):
|
||||
|
||||
conflict = await User.filter(whatsapp_number=number).exclude(id=user_id).first()
|
||||
if conflict:
|
||||
return jsonify({"error": "That WhatsApp number is already linked to another account"}), 409
|
||||
return jsonify(
|
||||
{"error": "That WhatsApp number is already linked to another account"}
|
||||
), 409
|
||||
|
||||
user.whatsapp_number = number
|
||||
await user.save()
|
||||
return jsonify({
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"whatsapp_number": user.whatsapp_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"whatsapp_number": user.whatsapp_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["DELETE"])
|
||||
@@ -279,11 +293,55 @@ async def unlink_whatsapp(user_id):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/imessage", methods=["PUT"])
|
||||
@admin_required
|
||||
async def set_imessage(user_id):
|
||||
data = await request.get_json()
|
||||
number = (data or {}).get("imessage_number", "").strip()
|
||||
if not number:
|
||||
return jsonify({"error": "imessage_number is required"}), 400
|
||||
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
conflict = await User.filter(imessage_number=number).exclude(id=user_id).first()
|
||||
if conflict:
|
||||
return jsonify(
|
||||
{"error": "That iMessage number is already linked to another account"}
|
||||
), 409
|
||||
|
||||
user.imessage_number = number
|
||||
await user.save()
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"imessage_number": user.imessage_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/imessage", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def unlink_imessage(user_id):
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
user.imessage_number = None
|
||||
await user.save()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["PUT"])
|
||||
@admin_required
|
||||
async def toggle_email(user_id):
|
||||
"""Enable email channel for a user, generating an HMAC token."""
|
||||
from blueprints.email.helpers import generate_email_token, get_user_email_address
|
||||
|
||||
user = await User.get_or_none(id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
@@ -299,15 +357,19 @@ async def toggle_email(user_id):
|
||||
user.email_enabled = True
|
||||
await user.save()
|
||||
|
||||
return jsonify({
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"whatsapp_number": user.whatsapp_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
"email_enabled": user.email_enabled,
|
||||
"email_address": get_user_email_address(user.email_hmac_token, mailgun_domain),
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"whatsapp_number": user.whatsapp_number,
|
||||
"auth_provider": user.auth_provider,
|
||||
"email_enabled": user.email_enabled,
|
||||
"email_address": get_user_email_address(
|
||||
user.email_hmac_token, mailgun_domain
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@user_blueprint.route("/admin/users/<user_id>/email", methods=["DELETE"])
|
||||
|
||||
@@ -10,11 +10,18 @@ class User(Model):
|
||||
username = fields.CharField(max_length=255)
|
||||
password = fields.BinaryField(null=True) # Hashed - nullable for OIDC users
|
||||
email = fields.CharField(max_length=100, unique=True)
|
||||
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=True)
|
||||
whatsapp_number = fields.CharField(
|
||||
max_length=30, unique=True, null=True, index=True
|
||||
)
|
||||
imessage_number = fields.CharField(
|
||||
max_length=30, unique=True, null=True, index=True
|
||||
)
|
||||
|
||||
# Email channel fields
|
||||
email_enabled = fields.BooleanField(default=False)
|
||||
email_hmac_token = fields.CharField(max_length=16, unique=True, null=True, index=True)
|
||||
email_hmac_token = fields.CharField(
|
||||
max_length=16, unique=True, null=True, index=True
|
||||
)
|
||||
|
||||
# OIDC fields
|
||||
oidc_subject = fields.CharField(
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import functools
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from quart import Blueprint, request, jsonify, abort
|
||||
from quart import Blueprint, request, abort
|
||||
from twilio.request_validator import RequestValidator
|
||||
from twilio.twiml.messaging_response import MessagingResponse
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
get_conversation_for_user,
|
||||
get_agent_for,
|
||||
get_conversation_for_channel,
|
||||
add_message_to_conversation,
|
||||
get_conversation_transcript,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
|
||||
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
|
||||
|
||||
@@ -69,6 +68,7 @@ def validate_twilio_request(f):
|
||||
so the validated URL matches what Twilio signed against.
|
||||
Set TWILIO_SIGNATURE_VALIDATION=false to disable in development.
|
||||
"""
|
||||
|
||||
@functools.wraps(f)
|
||||
async def decorated_function(*args, **kwargs):
|
||||
if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false":
|
||||
@@ -94,6 +94,7 @@ def validate_twilio_request(f):
|
||||
abort(403)
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@@ -104,11 +105,15 @@ async def webhook():
|
||||
Handle incoming WhatsApp messages from Twilio.
|
||||
"""
|
||||
form_data = await request.form
|
||||
from_number = form_data.get("From") # e.g., "whatsapp:+1234567890"
|
||||
from_number = form_data.get("From") # e.g., "whatsapp:+1234567890"
|
||||
body = form_data.get("Body")
|
||||
|
||||
if not from_number or not body:
|
||||
return _twiml_response("Invalid message received.") if from_number else ("Missing From or Body", 400)
|
||||
return (
|
||||
_twiml_response("Invalid message received.")
|
||||
if from_number
|
||||
else ("Missing From or Body", 400)
|
||||
)
|
||||
|
||||
# Strip whitespace and check for empty body
|
||||
body = body.strip()
|
||||
@@ -118,12 +123,16 @@ async def webhook():
|
||||
# Rate limiting
|
||||
if not _check_rate_limit(from_number):
|
||||
logger.warning(f"Rate limit exceeded for {from_number}")
|
||||
return _twiml_response("You're sending messages too quickly. Please wait a moment and try again.")
|
||||
return _twiml_response(
|
||||
"You're sending messages too quickly. Please wait a moment and try again."
|
||||
)
|
||||
|
||||
# Truncate overly long messages
|
||||
if len(body) > MAX_MESSAGE_LENGTH:
|
||||
body = body[:MAX_MESSAGE_LENGTH]
|
||||
logger.info(f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars")
|
||||
logger.info(
|
||||
f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars"
|
||||
)
|
||||
|
||||
logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}")
|
||||
|
||||
@@ -143,17 +152,18 @@ async def webhook():
|
||||
username=username,
|
||||
email=f"{username}@whatsapp.simbarag.local",
|
||||
whatsapp_number=from_number,
|
||||
auth_provider="whatsapp"
|
||||
auth_provider="whatsapp",
|
||||
)
|
||||
logger.info(f"Created new user for WhatsApp: {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create user for {from_number}: {e}")
|
||||
return _twiml_response("Sorry, something went wrong setting up your account. Please try again later.")
|
||||
return _twiml_response(
|
||||
"Sorry, something went wrong setting up your account. Please try again later."
|
||||
)
|
||||
|
||||
# Get or create a conversation for this user
|
||||
try:
|
||||
conversation = await get_conversation_for_user(user=user)
|
||||
await conversation.fetch_related("messages")
|
||||
conversation = await get_conversation_for_channel(user=user, channel="whatsapp")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||
return _twiml_response("Sorry, something went wrong. Please try again later.")
|
||||
@@ -166,30 +176,23 @@ async def webhook():
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Get transcript for context
|
||||
transcript = await get_conversation_transcript(user=user, conversation=conversation)
|
||||
|
||||
# Build messages payload for LangChain agent with system prompt and conversation history
|
||||
try:
|
||||
# Get last 10 messages for conversation history
|
||||
messages = await conversation.messages.all()
|
||||
recent_messages = list(messages)[-10:]
|
||||
|
||||
# Build messages payload
|
||||
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||
|
||||
# Add recent conversation history (exclude the message we just added)
|
||||
for msg in recent_messages[:-1]:
|
||||
role = "user" if msg.speaker == "user" else "assistant"
|
||||
messages_payload.append({"role": role, "content": msg.text})
|
||||
|
||||
# Add current query
|
||||
messages_payload.append({"role": "user", "content": body})
|
||||
|
||||
# Invoke LangChain agent
|
||||
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
|
||||
response = await main_agent.ainvoke({"messages": messages_payload})
|
||||
response_text = response.get("messages", [])[-1].content
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
}
|
||||
}
|
||||
logger.info(f"Invoking LangChain agent for conversation {conversation.id}")
|
||||
response = await get_agent_for(user).ainvoke(
|
||||
{"messages": [{"role": "user", "content": body}]},
|
||||
config=agent_config,
|
||||
)
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
# Log YNAB availability
|
||||
if os.getenv("YNAB_ACCESS_TOKEN"):
|
||||
|
||||
@@ -16,6 +16,9 @@ TORTOISE_CONFIG = {
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.users.models",
|
||||
"blueprints.email.models",
|
||||
"blueprints.scheduled_messages.models",
|
||||
"blueprints.cron.models",
|
||||
"blueprints.beli.models",
|
||||
"aerich.models",
|
||||
],
|
||||
"default_connection": "default",
|
||||
|
||||
+107
-50
@@ -1,8 +1,88 @@
|
||||
version: "3.8"
|
||||
|
||||
x-raggr-common: &raggr-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: torrtle/simbarag:latest
|
||||
environment:
|
||||
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
|
||||
- BASE_URL=${BASE_URL}
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- EMBEDDING_SERVER_URL=${EMBEDDING_SERVER_URL}
|
||||
- EMBEDDING_MODEL_NAME=${EMBEDDING_MODEL_NAME}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
|
||||
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
|
||||
- OIDC_USE_DISCOVERY=${OIDC_USE_DISCOVERY:-true}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgres://raggr:changeme@postgres:5432/raggr}
|
||||
- TAVILY_API_KEY=${TAVILIY_API_KEY}
|
||||
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
|
||||
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
|
||||
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
|
||||
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
|
||||
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
||||
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
|
||||
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
|
||||
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
|
||||
- OBSIDIAN_EMAIL=${OBSIDIAN_EMAIL}
|
||||
- OBSIDIAN_PASSWORD=${OBSIDIAN_PASSWORD}
|
||||
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
|
||||
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
|
||||
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
||||
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-asksimba-images}
|
||||
- S3_REGION=${S3_REGION:-garage}
|
||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
|
||||
- FERNET_KEY=${FERNET_KEY}
|
||||
- GOOGLE_TOKEN_FILE=${GOOGLE_TOKEN_FILE:-/app/config/google-token.json}
|
||||
- MAILGUN_API_KEY=${MAILGUN_API_KEY}
|
||||
- MAILGUN_DOMAIN=${MAILGUN_DOMAIN}
|
||||
- MAILGUN_WEBHOOK_SIGNING_KEY=${MAILGUN_WEBHOOK_SIGNING_KEY}
|
||||
- MAILGUN_SIGNATURE_VALIDATION=${MAILGUN_SIGNATURE_VALIDATION:-true}
|
||||
- EMAIL_RATE_LIMIT_MAX=${EMAIL_RATE_LIMIT_MAX:-5}
|
||||
- EMAIL_RATE_LIMIT_WINDOW=${EMAIL_RATE_LIMIT_WINDOW:-300}
|
||||
- SENDBLUE_API_KEY=${SENDBLUE_API_KEY}
|
||||
- SENDBLUE_API_SECRET=${SENDBLUE_API_SECRET}
|
||||
- SENDBLUE_FROM_NUMBER=${SENDBLUE_FROM_NUMBER}
|
||||
- SENDBLUE_WEBHOOK_SECRET=${SENDBLUE_WEBHOOK_SECRET}
|
||||
- SENDBLUE_SIGNATURE_VALIDATION=${SENDBLUE_SIGNATURE_VALIDATION:-true}
|
||||
- ALLOWED_IMESSAGE_NUMBERS=${ALLOWED_IMESSAGE_NUMBERS}
|
||||
- SIMBAKIT_URL=${SIMBAKIT_URL:-http://simbakit:8585}
|
||||
- CRON_SECRET=${CRON_SECRET}
|
||||
- CRONICLE_URL=${CRONICLE_URL:-http://cronicle:3012}
|
||||
- CRONICLE_API_KEY=${CRONICLE_API_KEY}
|
||||
- HOMEBOX_URL=${HOMEBOX_URL}
|
||||
- HOMEBOX_TOKEN=${HOMEBOX_TOKEN}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./obvault:/app/data/obsidian
|
||||
- ./token.json:/app/config/google-token.json:ro
|
||||
- raggr-checkpoints:/app/checkpoints
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
@@ -11,6 +91,7 @@ services:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-raggr}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/init-pgvector.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-raggr}"]
|
||||
interval: 10s
|
||||
@@ -18,58 +99,34 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
raggr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: torrtle/simbarag:latest
|
||||
# Blue-green deployment: exactly one of raggr-blue/raggr-green serves at a
|
||||
# time; Caddy health-checks both ports and routes to the healthy one.
|
||||
# Use scripts/deploy.sh (make redeploy) to swap colors with zero downtime.
|
||||
raggr-blue:
|
||||
<<: *raggr-common
|
||||
profiles: ["blue"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PAPERLESS_TOKEN=${PAPERLESS_TOKEN}
|
||||
- BASE_URL=${BASE_URL}
|
||||
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
|
||||
- CHROMADB_PATH=/app/data/chromadb
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
|
||||
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
|
||||
- OIDC_USE_DISCOVERY=${OIDC_USE_DISCOVERY:-true}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgres://raggr:changeme@postgres:5432/raggr}
|
||||
- TAVILY_API_KEY=${TAVILIY_API_KEY}
|
||||
- YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN}
|
||||
- YNAB_BUDGET_ID=${YNAB_BUDGET_ID}
|
||||
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
|
||||
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
|
||||
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
|
||||
- ALLOWED_WHATSAPP_NUMBERS=${ALLOWED_WHATSAPP_NUMBERS}
|
||||
- TWILIO_SIGNATURE_VALIDATION=${TWILIO_SIGNATURE_VALIDATION:-true}
|
||||
- TWILIO_WEBHOOK_URL=${TWILIO_WEBHOOK_URL:-}
|
||||
- OBSIDIAN_AUTH_TOKEN=${OBSIDIAN_AUTH_TOKEN}
|
||||
- OBSIDIAN_VAULT_ID=${OBSIDIAN_VAULT_ID}
|
||||
- OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD}
|
||||
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
||||
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-asksimba-images}
|
||||
- S3_REGION=${S3_REGION:-garage}
|
||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
|
||||
- FERNET_KEY=${FERNET_KEY}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- chromadb_data:/app/data/chromadb
|
||||
- ./obvault:/app/data/obsidian
|
||||
|
||||
raggr-green:
|
||||
<<: *raggr-common
|
||||
profiles: ["green"]
|
||||
ports:
|
||||
- "8081:8080"
|
||||
|
||||
simbakit:
|
||||
build:
|
||||
context: ./simbakit
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- simbakit-data:/app/data
|
||||
environment:
|
||||
- PETKIT_USERNAME=${PETKIT_USERNAME}
|
||||
- PETKIT_PASSWORD=${PETKIT_PASSWORD}
|
||||
- PETKIT_REGION=${PETKIT_REGION:-US}
|
||||
- PETKIT_TIMEZONE=${PETKIT_TIMEZONE:-America/New_York}
|
||||
|
||||
volumes:
|
||||
chromadb_data:
|
||||
postgres_data:
|
||||
simbakit-data:
|
||||
raggr-checkpoints:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
+76
-8
@@ -96,20 +96,88 @@ The dev environment:
|
||||
- Rebuilds frontend on changes
|
||||
- Auto-applies migrations on startup
|
||||
|
||||
### Production
|
||||
### Production (zero-downtime blue-green)
|
||||
|
||||
The app runs as one of two identical compose services: `raggr-blue`
|
||||
(host port 8080) or `raggr-green` (host port 8081), gated behind compose
|
||||
profiles so only one color runs at a time. Caddy health-checks both ports and
|
||||
routes traffic to whichever is healthy.
|
||||
|
||||
```bash
|
||||
# Build and deploy
|
||||
docker compose build raggr
|
||||
docker compose up -d
|
||||
# Pull latest and swap colors with zero downtime
|
||||
make redeploy
|
||||
|
||||
# View logs
|
||||
docker compose logs -f raggr
|
||||
# Or run the swap directly
|
||||
./scripts/deploy.sh
|
||||
|
||||
# Verify migrations applied
|
||||
docker compose exec raggr aerich history
|
||||
# View logs / exec into the active color (Makefile resolves the color)
|
||||
make logs
|
||||
make migrate-history
|
||||
```
|
||||
|
||||
`scripts/deploy.sh` does the following:
|
||||
|
||||
1. Detects the running color via `docker compose ps`.
|
||||
2. Builds the inactive color.
|
||||
3. Starts it with `docker compose up -d --wait`, which blocks until the
|
||||
container healthcheck (`GET /api/health`) passes.
|
||||
4. If the new color never becomes healthy, it is stopped and the old color
|
||||
keeps serving (the script exits non-zero).
|
||||
5. Otherwise the old color is stopped with a 30s grace period so in-flight
|
||||
SSE streams can finish, then removed.
|
||||
|
||||
To target a color manually, enable its profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile blue up -d raggr-blue
|
||||
docker compose --profile blue --profile green ps
|
||||
```
|
||||
|
||||
#### Caddy configuration
|
||||
|
||||
Caddy needs both upstreams with active health checks:
|
||||
|
||||
```caddyfile
|
||||
ask.torrtle.co {
|
||||
reverse_proxy host.docker.internal:8080 host.docker.internal:8081 {
|
||||
lb_policy first # prefer blue when both are healthy
|
||||
lb_try_duration 5s # retry on the other upstream during the swap
|
||||
lb_try_interval 250ms
|
||||
health_uri /api/health
|
||||
health_interval 2s
|
||||
health_timeout 2s
|
||||
fail_duration 10s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### One-time cutover from the old `raggr` service
|
||||
|
||||
The first deploy after this change lands needs manual steps, because the old
|
||||
`raggr` container still holds port 8080 and the deploy script doesn't know
|
||||
about it:
|
||||
|
||||
```bash
|
||||
# 1. Update the Caddy config to the two-upstream block below and reload Caddy
|
||||
# 2. On the server:
|
||||
git pull
|
||||
docker compose --profile green up -d --build --wait raggr-green # port 8081, no conflict
|
||||
docker stop -t 30 $(docker ps -q --filter name=raggr-1) # old raggr container
|
||||
docker rm $(docker ps -aq --filter name=raggr-1)
|
||||
# From here on, `make redeploy` handles swaps (green is now the active color)
|
||||
```
|
||||
|
||||
#### Blue-green caveats
|
||||
|
||||
- **Migrations must be backward-compatible.** The new color runs
|
||||
`aerich upgrade` on startup while the old color is still serving the
|
||||
previous code. Additive changes (new tables/columns) are safe; destructive
|
||||
changes (drop/rename a column the old code reads) need a two-step deploy:
|
||||
ship code that stops using the column first, then drop it in a later deploy.
|
||||
- **Obsidian sync overlap.** During the ~30s swap window both containers run
|
||||
pull-only `ob sync` against the shared `./obvault` mount. Both are
|
||||
read-only pulls, so this is low risk, but avoid deploying mid-reindex.
|
||||
|
||||
### Fresh Deploy (New Database)
|
||||
|
||||
On first deploy with an empty database, `startup-dev.sh` runs `aerich init-db` instead of `aerich upgrade`. This creates all tables from the current models.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -13,7 +14,15 @@ class LLMClient:
|
||||
def __init__(self):
|
||||
llama_url = os.getenv("LLAMA_SERVER_URL")
|
||||
if llama_url:
|
||||
self.client = OpenAI(base_url=llama_url, api_key="not-needed")
|
||||
# Tight read timeout so a wedged llama-server fails fast instead
|
||||
# of hanging for the OpenAI client's 600s default.
|
||||
read_timeout = float(os.getenv("LLAMA_TIMEOUT_SECONDS", "120"))
|
||||
self.client = OpenAI(
|
||||
base_url=llama_url,
|
||||
api_key="not-needed",
|
||||
timeout=httpx.Timeout(read_timeout, connect=10.0),
|
||||
max_retries=1,
|
||||
)
|
||||
self.model = os.getenv("LLAMA_MODEL_NAME", "llama-3.1-8b-instruct")
|
||||
self.PROVIDER = "llama_server"
|
||||
logging.info("Using llama_server as LLM backend")
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import chromadb
|
||||
from utils.chunker import Chunker
|
||||
from utils.cleaner import pdf_to_image, summarize_pdf_image
|
||||
from llm import LLMClient
|
||||
from scripts.query import QueryGenerator
|
||||
from utils.request import PaperlessNGXService
|
||||
|
||||
_dotenv_loaded = load_dotenv()
|
||||
|
||||
client = chromadb.PersistentClient(path=os.getenv("CHROMADB_PATH", ""))
|
||||
simba_docs = client.get_or_create_collection(name="simba_docs2")
|
||||
feline_vet_lookup = client.get_or_create_collection(name="feline_vet_lookup")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="An LLM tool to query information about Simba <3"
|
||||
)
|
||||
|
||||
parser.add_argument("query", type=str, help="questions about simba's health")
|
||||
parser.add_argument(
|
||||
"--reindex", action="store_true", help="re-index the simba documents"
|
||||
)
|
||||
parser.add_argument("--classify", action="store_true", help="test classification")
|
||||
parser.add_argument("--index", help="index a file")
|
||||
|
||||
ppngx = PaperlessNGXService()
|
||||
|
||||
llm_client = LLMClient()
|
||||
|
||||
|
||||
def index_using_pdf_llm(doctypes):
|
||||
logging.info("reindex data...")
|
||||
files = ppngx.get_data()
|
||||
for file in files:
|
||||
document_id: int = file["id"]
|
||||
pdf_path = ppngx.download_pdf_from_id(id=document_id)
|
||||
image_paths = pdf_to_image(filepath=pdf_path)
|
||||
logging.info(f"summarizing {file}")
|
||||
generated_summary = summarize_pdf_image(filepaths=image_paths)
|
||||
file["content"] = generated_summary
|
||||
|
||||
chunk_data(files, simba_docs, doctypes=doctypes)
|
||||
|
||||
|
||||
def date_to_epoch(date_str: str) -> float:
|
||||
split_date = date_str.split("-")
|
||||
date = datetime.datetime(
|
||||
int(split_date[0]),
|
||||
int(split_date[1]),
|
||||
int(split_date[2]),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
return date.timestamp()
|
||||
|
||||
|
||||
def chunk_data(docs, collection, doctypes):
|
||||
# Step 2: Create chunks
|
||||
chunker = Chunker(collection)
|
||||
|
||||
logging.info(f"chunking {len(docs)} documents")
|
||||
texts: list[str] = [doc["content"] for doc in docs]
|
||||
with sqlite3.connect("database/visited.db") as conn:
|
||||
to_insert = []
|
||||
c = conn.cursor()
|
||||
for index, text in enumerate(texts):
|
||||
metadata = {
|
||||
"created_date": date_to_epoch(docs[index]["created_date"]),
|
||||
"filename": docs[index]["original_file_name"],
|
||||
"document_type": doctypes.get(docs[index]["document_type"], ""),
|
||||
}
|
||||
|
||||
if doctypes:
|
||||
metadata["type"] = doctypes.get(docs[index]["document_type"])
|
||||
|
||||
chunker.chunk_document(
|
||||
document=text,
|
||||
metadata=metadata,
|
||||
)
|
||||
to_insert.append((docs[index]["id"],))
|
||||
|
||||
c.executemany(
|
||||
"INSERT INTO indexed_documents (paperless_id) values (?)", to_insert
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def chunk_text(texts: list[str], collection):
|
||||
chunker = Chunker(collection)
|
||||
|
||||
for index, text in enumerate(texts):
|
||||
metadata = {}
|
||||
chunker.chunk_document(
|
||||
document=text,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def classify_query(query: str, transcript: str) -> bool:
|
||||
logging.info("Starting query generation")
|
||||
qg_start = time.time()
|
||||
qg = QueryGenerator()
|
||||
query_type = qg.get_query_type(input=query, transcript=transcript)
|
||||
logging.info(query_type)
|
||||
qg_end = time.time()
|
||||
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
|
||||
return query_type == "Simba"
|
||||
|
||||
|
||||
def consult_oracle(
|
||||
input: str,
|
||||
collection,
|
||||
transcript: str = "",
|
||||
):
|
||||
chunker = Chunker(collection)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Ask
|
||||
logging.info("Starting query generation")
|
||||
qg_start = time.time()
|
||||
qg = QueryGenerator()
|
||||
doctype_query = qg.get_doctype_query(input=input)
|
||||
# metadata_filter = qg.get_query(input)
|
||||
metadata_filter = {**doctype_query}
|
||||
logging.info(metadata_filter)
|
||||
qg_end = time.time()
|
||||
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
|
||||
|
||||
logging.info("Starting embedding generation")
|
||||
embedding_start = time.time()
|
||||
embeddings = chunker.embedding_fx(inputs=[input])
|
||||
embedding_end = time.time()
|
||||
logging.info(
|
||||
f"Embedding generation took {embedding_end - embedding_start:.2f} seconds"
|
||||
)
|
||||
|
||||
logging.info("Starting collection query")
|
||||
query_start = time.time()
|
||||
results = collection.query(
|
||||
query_texts=[input],
|
||||
query_embeddings=embeddings,
|
||||
where=metadata_filter,
|
||||
)
|
||||
query_end = time.time()
|
||||
logging.info(f"Collection query took {query_end - query_start:.2f} seconds")
|
||||
|
||||
# Generate
|
||||
logging.info("Starting LLM generation")
|
||||
llm_start = time.time()
|
||||
system_prompt = "You are a helpful assistant that understands veterinary terms."
|
||||
transcript_prompt = f"Here is the message transcript thus far {transcript}."
|
||||
prompt = f"""Using the following data, help answer the user's query by providing as many details as possible.
|
||||
Using this data: {results}. {transcript_prompt if len(transcript) > 0 else ""}
|
||||
Respond to this prompt: {input}"""
|
||||
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
|
||||
llm_end = time.time()
|
||||
logging.info(f"LLM generation took {llm_end - llm_start:.2f} seconds")
|
||||
|
||||
total_time = time.time() - start_time
|
||||
logging.info(f"Total consult_oracle execution took {total_time:.2f} seconds")
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def llm_chat(input: str, transcript: str = "") -> str:
|
||||
system_prompt = "You are a helpful assistant that understands veterinary terms."
|
||||
transcript_prompt = f"Here is the message transcript thus far {transcript}."
|
||||
prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||
{transcript_prompt if len(transcript) > 0 else ""}
|
||||
Respond to this prompt: {input}"""
|
||||
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
|
||||
return output
|
||||
|
||||
|
||||
def paperless_workflow(input):
|
||||
# Step 1: Get the text
|
||||
ppngx = PaperlessNGXService()
|
||||
docs = ppngx.get_data()
|
||||
|
||||
chunk_data(docs, collection=simba_docs)
|
||||
consult_oracle(input, simba_docs)
|
||||
|
||||
|
||||
def consult_simba_oracle(input: str, transcript: str = ""):
|
||||
is_simba_related = classify_query(query=input, transcript=transcript)
|
||||
|
||||
if is_simba_related:
|
||||
logging.info("Query is related to simba")
|
||||
return consult_oracle(
|
||||
input=input,
|
||||
collection=simba_docs,
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
logging.info("Query is NOT related to simba")
|
||||
|
||||
return llm_chat(input=input, transcript=transcript)
|
||||
|
||||
|
||||
def filter_indexed_files(docs):
|
||||
with sqlite3.connect("database/visited.db") as conn:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
|
||||
)
|
||||
c.execute("SELECT paperless_id FROM indexed_documents")
|
||||
rows = c.fetchall()
|
||||
conn.commit()
|
||||
|
||||
visited = {row[0] for row in rows}
|
||||
return [doc for doc in docs if doc["id"] not in visited]
|
||||
|
||||
|
||||
def reindex():
|
||||
with sqlite3.connect("database/visited.db") as conn:
|
||||
c = conn.cursor()
|
||||
# Ensure the table exists before trying to delete from it
|
||||
c.execute(
|
||||
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
|
||||
)
|
||||
c.execute("DELETE FROM indexed_documents")
|
||||
conn.commit()
|
||||
|
||||
# Delete all documents from the collection
|
||||
all_docs = simba_docs.get()
|
||||
if all_docs["ids"]:
|
||||
simba_docs.delete(ids=all_docs["ids"])
|
||||
|
||||
logging.info("Fetching documents from Paperless-NGX")
|
||||
ppngx = PaperlessNGXService()
|
||||
docs = ppngx.get_data()
|
||||
docs = filter_indexed_files(docs)
|
||||
logging.info(f"Fetched {len(docs)} documents")
|
||||
|
||||
# Delete all chromadb data
|
||||
ids = simba_docs.get(ids=None, limit=None, offset=0)
|
||||
all_ids = ids["ids"]
|
||||
if len(all_ids) > 0:
|
||||
simba_docs.delete(ids=all_ids)
|
||||
|
||||
# Chunk documents
|
||||
logging.info("Chunking documents now ...")
|
||||
doctype_lookup = ppngx.get_doctypes()
|
||||
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
|
||||
logging.info("Done chunking documents")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
if args.reindex:
|
||||
reindex()
|
||||
|
||||
if args.classify:
|
||||
consult_simba_oracle(input="yohohoho testing")
|
||||
consult_simba_oracle(input="write an email")
|
||||
consult_simba_oracle(input="how much does simba weigh")
|
||||
|
||||
if args.query:
|
||||
logging.info("Consulting oracle ...")
|
||||
print(
|
||||
consult_oracle(
|
||||
input=args.query,
|
||||
collection=simba_docs,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logging.info("please provide a query")
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Histogram,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
REGISTRY = CollectorRegistry(auto_describe=True)
|
||||
|
||||
CHAT_RESPONSE_SECONDS = Histogram(
|
||||
"raggr_chat_response_seconds",
|
||||
"End-to-end chat response time in seconds.",
|
||||
labelnames=("stream",),
|
||||
buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
CHAT_REQUESTS_TOTAL = Counter(
|
||||
"raggr_chat_requests_total",
|
||||
"Total chat requests by stream mode and result.",
|
||||
labelnames=("stream", "result"),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
def _s(stream: bool) -> str:
|
||||
return "true" if stream else "false"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def time_response(stream: bool):
|
||||
t0 = time.monotonic()
|
||||
result = "success"
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
result = "error"
|
||||
raise
|
||||
finally:
|
||||
CHAT_RESPONSE_SECONDS.labels(stream=_s(stream)).observe(time.monotonic() - t0)
|
||||
CHAT_REQUESTS_TOTAL.labels(stream=_s(stream), result=result).inc()
|
||||
|
||||
|
||||
def observe_response(stream: bool, seconds: float) -> None:
|
||||
CHAT_RESPONSE_SECONDS.labels(stream=_s(stream)).observe(seconds)
|
||||
|
||||
|
||||
def count_result(stream: bool, result: str) -> None:
|
||||
CHAT_REQUESTS_TOTAL.labels(stream=_s(stream), result=result).inc()
|
||||
|
||||
|
||||
def render_metrics() -> bytes:
|
||||
return generate_latest(REGISTRY)
|
||||
@@ -0,0 +1,71 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "conversation_messages" ADD "message_handle" VARCHAR(255);
|
||||
COMMENT ON COLUMN "conversation_messages"."speaker" IS 'USER: user\nSIMBA: simba\nTOOL: tool';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "conversation_messages" DROP COLUMN "message_handle";
|
||||
COMMENT ON COLUMN "conversation_messages"."speaker" IS 'USER: user\nSIMBA: simba';"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "cron_jobs" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"description" TEXT,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"recipient" VARCHAR(255) NOT NULL,
|
||||
"channel" VARCHAR(20) NOT NULL,
|
||||
"enabled" BOOL NOT NULL DEFAULT True,
|
||||
"last_run_at" TIMESTAMPTZ,
|
||||
"last_status" VARCHAR(50),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON COLUMN "cron_jobs"."channel" IS 'IMESSAGE: imessage\nEMAIL: email';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "cron_jobs";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
"XV6+/x9WSMI2"
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "cron_jobs" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"description" TEXT,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"recipient" VARCHAR(255) NOT NULL,
|
||||
"channel" VARCHAR(20) NOT NULL,
|
||||
"enabled" BOOL NOT NULL DEFAULT True,
|
||||
"last_run_at" TIMESTAMPTZ,
|
||||
"last_status" VARCHAR(50),
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON COLUMN "cron_jobs"."channel" IS 'IMESSAGE: imessage\nEMAIL: email';
|
||||
CREATE TABLE IF NOT EXISTS "beli_profiles" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"beli_email" VARCHAR(255) NOT NULL,
|
||||
"beli_password" TEXT NOT NULL,
|
||||
"beli_user_id" VARCHAR(255),
|
||||
"beli_refresh_token" TEXT,
|
||||
"last_refreshed_at" TIMESTAMPTZ,
|
||||
"last_error" TEXT,
|
||||
"enabled" BOOL NOT NULL DEFAULT True,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_beli_profil_user_id_f4bba0" UNIQUE ("user_id", "beli_email")
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "beli_restaurants" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"beli_business_id" VARCHAR(255) NOT NULL,
|
||||
"name" VARCHAR(500) NOT NULL,
|
||||
"city" VARCHAR(255),
|
||||
"neighborhood" VARCHAR(255),
|
||||
"cuisine" JSONB NOT NULL,
|
||||
"price_range" VARCHAR(10),
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"rank_index" INT,
|
||||
"score" DECIMAL(5,2),
|
||||
"notes" TEXT,
|
||||
"tags" JSONB NOT NULL,
|
||||
"fetched_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"profile_id" UUID NOT NULL REFERENCES "beli_profiles" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_beli_restau_profile_921fe6" UNIQUE ("profile_id", "beli_business_id", "status")
|
||||
);
|
||||
COMMENT ON COLUMN "beli_restaurants"."status" IS 'BEEN: been\nWANT_TO_TRY: want_to_try';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "beli_profiles";
|
||||
DROP TABLE IF EXISTS "cron_jobs";
|
||||
DROP TABLE IF EXISTS "beli_restaurants";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "beli_restaurants" DROP CONSTRAINT IF EXISTS "uid_beli_restau_profile_921fe6";
|
||||
ALTER TABLE "beli_restaurants" ADD "friend_name" VARCHAR(255);
|
||||
ALTER TABLE "beli_restaurants" ADD "friend_beli_user_id" VARCHAR(255) NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uid_beli_restau_profile_1e4068" ON "beli_restaurants" ("profile_id", "beli_business_id", "status", "friend_beli_user_id");"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "uid_beli_restau_profile_1e4068";
|
||||
ALTER TABLE "beli_restaurants" DROP COLUMN "friend_name";
|
||||
ALTER TABLE "beli_restaurants" DROP COLUMN "friend_beli_user_id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uid_beli_restau_profile_921fe6" ON "beli_restaurants" ("profile_id", "beli_business_id", "status");"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "uid_beli_restau_profile_1e4068";
|
||||
ALTER TABLE "beli_restaurants" ADD "beli_category" VARCHAR(10) NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uid_beli_restau_profile_93dc73" ON "beli_restaurants" ("profile_id", "beli_business_id", "status", "friend_beli_user_id", "beli_category");"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "uid_beli_restau_profile_93dc73";
|
||||
ALTER TABLE "beli_restaurants" DROP COLUMN "beli_category";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uid_beli_restau_profile_1e4068" ON "beli_restaurants" ("profile_id", "beli_business_id", "status", "friend_beli_user_id");"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "user_memories" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "email_accounts" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"email_address" VARCHAR(255) NOT NULL UNIQUE,
|
||||
"display_name" VARCHAR(255),
|
||||
"imap_host" VARCHAR(255) NOT NULL,
|
||||
"imap_port" INT NOT NULL DEFAULT 993,
|
||||
"imap_username" VARCHAR(255) NOT NULL,
|
||||
"imap_password" TEXT NOT NULL,
|
||||
"is_active" BOOL NOT NULL DEFAULT True,
|
||||
"last_error" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "email_accounts" IS 'Email account configuration for IMAP connections.';
|
||||
CREATE TABLE IF NOT EXISTS "emails" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"message_id" VARCHAR(255) NOT NULL UNIQUE,
|
||||
"subject" VARCHAR(500) NOT NULL,
|
||||
"from_address" VARCHAR(255) NOT NULL,
|
||||
"to_address" TEXT NOT NULL,
|
||||
"date" TIMESTAMPTZ NOT NULL,
|
||||
"body_text" TEXT,
|
||||
"body_html" TEXT,
|
||||
"chromadb_doc_id" VARCHAR(255),
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_emails_message_981ddd" ON "emails" ("message_id");
|
||||
COMMENT ON TABLE "emails" IS 'Email message metadata and content.';
|
||||
CREATE TABLE IF NOT EXISTS "email_sync_status" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"last_sync_date" TIMESTAMPTZ,
|
||||
"last_message_uid" INT NOT NULL DEFAULT 0,
|
||||
"message_count" INT NOT NULL DEFAULT 0,
|
||||
"consecutive_failures" INT NOT NULL DEFAULT 0,
|
||||
"last_failure_date" TIMESTAMPTZ,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "email_sync_status" IS 'Tracks sync progress and state per email account.';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "user_memories";
|
||||
DROP TABLE IF EXISTS "email_accounts";
|
||||
DROP TABLE IF EXISTS "emails";
|
||||
DROP TABLE IF EXISTS "email_sync_status";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
+9
-2
@@ -5,7 +5,8 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"chromadb>=1.1.0",
|
||||
"langchain-postgres>=0.0.13",
|
||||
"psycopg[binary]>=3.1.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"flask>=3.1.2",
|
||||
"httpx>=0.28.1",
|
||||
@@ -30,8 +31,9 @@ dependencies = [
|
||||
"asyncpg>=0.30.0",
|
||||
"langchain-openai>=1.1.6",
|
||||
"langchain>=1.2.0",
|
||||
"langchain-chroma>=1.0.0",
|
||||
"langchain-community>=0.4.1",
|
||||
"langgraph-checkpoint-sqlite>=2.0.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
"jq>=1.10.0",
|
||||
"tavily-python>=0.7.17",
|
||||
"ynab>=1.3.0",
|
||||
@@ -40,6 +42,11 @@ dependencies = [
|
||||
"ollama>=0.6.1",
|
||||
"twilio>=9.10.2",
|
||||
"aioboto3>=13.0.0",
|
||||
"prometheus-client>=0.26.0",
|
||||
"google-api-python-client>=2.0.0",
|
||||
"google-auth-oauthlib>=1.0.0",
|
||||
"google-auth-httplib2>=0.2.0",
|
||||
"langchain-mcp-adapters>=0.3.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
|
||||
@@ -60,7 +60,7 @@ body {
|
||||
::-webkit-scrollbar-thumb { background: var(--color-sand); border-radius: 99px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-warm-gray); }
|
||||
|
||||
/* ── Markdown in answer bubbles ─────────────────────── */
|
||||
/* ── Markdown in assistant answers ──────────────────── */
|
||||
.markdown-content p { margin: 0.5em 0; line-height: 1.7; }
|
||||
.markdown-content p:first-child { margin-top: 0; }
|
||||
.markdown-content p:last-child { margin-bottom: 0; }
|
||||
@@ -117,6 +117,29 @@ body {
|
||||
.markdown-content strong { font-weight: 700; }
|
||||
.markdown-content em { font-style: italic; }
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
font-size: 0.9em;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid var(--color-sand-light);
|
||||
padding: 0.4em 0.6em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.markdown-content thead th {
|
||||
background: var(--color-sand-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
.markdown-content tbody tr:nth-child(even) {
|
||||
background: color-mix(in srgb, var(--color-sand-light) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ── Animations ─────────────────────────────────────── */
|
||||
@keyframes fadeSlideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
@@ -126,6 +149,14 @@ body {
|
||||
animation: fadeSlideUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.animate-drawer-in {
|
||||
animation: drawer-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes catPulse {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
|
||||
+20
-44
@@ -1,48 +1,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { ChatScreen } from "./components/ChatScreen";
|
||||
import { ConversationsPage } from "./components/ConversationsPage";
|
||||
import { LoginScreen } from "./components/LoginScreen";
|
||||
import { conversationService } from "./api/conversationService";
|
||||
import { useAuthCheck } from "./hooks/useAuthCheck";
|
||||
import catIcon from "./assets/cat.png";
|
||||
|
||||
const AppContainer = () => {
|
||||
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
|
||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
||||
const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
// No tokens at all, not authenticated
|
||||
if (!accessToken && !refreshToken) {
|
||||
setIsChecking(false);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to verify token by making a request
|
||||
try {
|
||||
await conversationService.getAllConversations();
|
||||
// If successful, user is authenticated
|
||||
setAuthenticated(true);
|
||||
} catch (error) {
|
||||
// Token is invalid or expired
|
||||
console.error("Authentication check failed:", error);
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
setAuthenticated(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||
@@ -58,14 +25,23 @@ const AppContainer = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginScreen setAuthenticated={setAuthenticated} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<ChatScreen setAuthenticated={setAuthenticated} />
|
||||
) : (
|
||||
<LoginScreen setAuthenticated={setAuthenticated} />
|
||||
)}
|
||||
</>
|
||||
<Routes>
|
||||
<Route path="/conversations" element={<ConversationsPage />} />
|
||||
{/* One ChatScreen instance serves both the home ("/") and per-conversation
|
||||
("/c/:id") routes. Using a single catch-all route keeps that instance
|
||||
mounted across the navigate() that fires when the first message creates
|
||||
a conversation — a remount here would abort the in-flight SSE stream
|
||||
(and drop the answer before it's persisted). */}
|
||||
<Route
|
||||
path="*"
|
||||
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: "thinking" }
|
||||
| { type: "thinking_delta"; delta: string }
|
||||
| { type: "tool_start"; tool: string }
|
||||
| { type: "tool_end"; tool: string }
|
||||
| { type: "content"; delta: string }
|
||||
| { type: "response"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
| { type: "error"; message: string; stalled?: boolean };
|
||||
|
||||
export type SSEEventCallback = (event: SSEEvent) => void;
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
speaker: "user" | "simba";
|
||||
speaker: "user" | "simba" | "tool";
|
||||
created_at: string;
|
||||
image_key?: string | null;
|
||||
}
|
||||
@@ -25,18 +28,10 @@ interface Conversation {
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface QueryRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface QueryResponse {
|
||||
response: string;
|
||||
}
|
||||
|
||||
interface CreateConversationRequest {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
class ConversationService {
|
||||
private baseUrl = "/api";
|
||||
private conversationBaseUrl = "/api/conversation";
|
||||
@@ -107,9 +102,51 @@ class ConversationService {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getAllConversations(): Promise<Conversation[]> {
|
||||
async renameConversation(
|
||||
conversationId: string,
|
||||
name: string,
|
||||
): Promise<Conversation> {
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.conversationBaseUrl}/`,
|
||||
`${this.conversationBaseUrl}/${conversationId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to rename conversation");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.conversationBaseUrl}/${conversationId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete conversation");
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConversations(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
}): Promise<Conversation[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit != null) search.set("limit", String(params.limit));
|
||||
if (params?.offset != null) search.set("offset", String(params.offset));
|
||||
if (params?.search) search.set("search", params.search);
|
||||
const qs = search.toString();
|
||||
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export interface ScheduledMessage {
|
||||
id: string;
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
content: string;
|
||||
subject: string | null;
|
||||
scheduled_at: string;
|
||||
status: "pending" | "sent" | "failed" | "cancelled";
|
||||
recurrence: "none" | "daily" | "weekly" | "monthly";
|
||||
use_agent: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScheduledMessage {
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
content: string;
|
||||
subject?: string;
|
||||
scheduled_at: string;
|
||||
recurrence?: "none" | "daily" | "weekly" | "monthly";
|
||||
use_agent?: boolean;
|
||||
}
|
||||
|
||||
class ScheduledMessageService {
|
||||
private baseUrl = "/api/scheduled-messages";
|
||||
|
||||
async list(): Promise<ScheduledMessage[]> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`);
|
||||
if (!response.ok) throw new Error("Failed to list scheduled messages");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async create(data: CreateScheduledMessage): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to create scheduled message");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to update scheduled message");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to delete scheduled message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduledMessageService = new ScheduledMessageService();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
||||
import { userService, type AdminUserRecord } from "../api/userService";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -12,27 +12,19 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
import { useAdminUsers } from "../hooks/useAdminUsers";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const AdminPanel = ({ onClose }: Props) => {
|
||||
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { users, loading, updateUser } = useAdminUsers();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [rowError, setRowError] = useState<Record<string, string>>({});
|
||||
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
userService
|
||||
.adminListUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const startEdit = (user: AdminUserRecord) => {
|
||||
setEditingId(user.id);
|
||||
setEditValue(user.whatsapp_number ?? "");
|
||||
@@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
|
||||
updateUser(userId, () => updated);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
|
||||
setEditingId(null);
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
@@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
await userService.adminUnlinkWhatsapp(userId);
|
||||
setUsers((p) =>
|
||||
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
||||
);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
|
||||
updateUser(userId, (u) => ({ ...u, whatsapp_number: null }));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((p) => ({
|
||||
@@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
const updated = await userService.adminToggleEmail(userId);
|
||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
|
||||
updateUser(userId, () => updated);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((p) => ({
|
||||
@@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||
try {
|
||||
await userService.adminDisableEmail(userId);
|
||||
setUsers((p) =>
|
||||
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
|
||||
);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
|
||||
updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null }));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
} catch (err) {
|
||||
setRowError((p) => ({
|
||||
@@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
|
||||
const copyToClipboard = (text: string, userId: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
|
||||
setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
|
||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||
};
|
||||
|
||||
@@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
"border border-sand-light/60",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
||||
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||
{loading ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
@@ -155,7 +141,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
Loading users…
|
||||
Loading users...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
: "text-warm-gray/40 italic",
|
||||
)}
|
||||
>
|
||||
{user.whatsapp_number ?? "—"}
|
||||
{user.whatsapp_number ?? "\u2014"}
|
||||
</span>
|
||||
{rowSuccess[user.id] && (
|
||||
<span className="text-xs text-leaf-dark">
|
||||
@@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-warm-gray/40 italic">—</span>
|
||||
<span className="text-sm text-warm-gray/40 italic">\u2014</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "../lib/utils";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
type AnswerBubbleProps = {
|
||||
text: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
||||
return (
|
||||
<div className="flex justify-start message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[78%] rounded-3xl rounded-bl-md",
|
||||
"bg-warm-white border border-sand-light/70",
|
||||
"shadow-sm shadow-sand/30",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* amber accent bar */}
|
||||
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
|
||||
export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
<div className="px-4 py-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-1.5 py-1 px-1">
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="markdown-content text-sm leading-relaxed text-charcoal">
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
const handleCopy = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable (e.g. insecure context) — ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-enter">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group relative -mx-3 -my-2 rounded-xl px-3 py-2 transition-colors duration-150 hover:bg-amber-soft/10">
|
||||
<div className="markdown-content text-[15px] leading-relaxed text-charcoal">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied" : "Copy message"}
|
||||
className="absolute top-0 right-0 flex h-7 w-7 items-center justify-center rounded-lg border border-sand-light/60 bg-warm-white text-warm-gray shadow-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-charcoal hover:bg-cream-dark focus:opacity-100 focus:outline-none cursor-pointer"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,239 +1,119 @@
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { userService } from "../api/userService";
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ToolBubble } from "./ToolBubble";
|
||||
import { ThinkingBubble } from "./ThinkingBubble";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { SidebarContent } from "./SidebarContent";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useConversations } from "../hooks/useConversations";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
type Message = {
|
||||
text: string;
|
||||
speaker: "simba" | "user" | "tool";
|
||||
image_key?: string | null;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ChatScreenProps = {
|
||||
setAuthenticated: (isAuth: boolean) => void;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
const TOOL_MESSAGES: Record<string, string> = {
|
||||
simba_search: "🔍 Searching Simba's records...",
|
||||
web_search: "🌐 Searching the web...",
|
||||
get_current_date: "📅 Checking today's date...",
|
||||
ynab_budget_summary: "💰 Checking budget summary...",
|
||||
ynab_search_transactions: "💳 Looking up transactions...",
|
||||
ynab_category_spending: "📊 Analyzing category spending...",
|
||||
ynab_insights: "📈 Generating budget insights...",
|
||||
obsidian_search_notes: "📝 Searching notes...",
|
||||
obsidian_read_note: "📖 Reading note...",
|
||||
obsidian_create_note: "✏️ Saving note...",
|
||||
obsidian_create_task: "✅ Creating task...",
|
||||
journal_get_today: "📔 Reading today's journal...",
|
||||
journal_get_tasks: "📋 Getting tasks...",
|
||||
journal_add_task: "➕ Adding task...",
|
||||
journal_complete_task: "✔️ Completing task...",
|
||||
};
|
||||
|
||||
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [showConversations, setShowConversations] = useState<boolean>(false);
|
||||
const [selectedConversation, setSelectedConversation] =
|
||||
useState<Conversation | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
||||
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [simbaMode, setSimbaMode] = useState(false);
|
||||
const [showConversations, setShowConversations] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
||||
const [showScheduler, setShowScheduler] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: isLoading ? "instant" : "smooth",
|
||||
behavior: isLoadingRef.current ? "instant" : "smooth",
|
||||
});
|
||||
});
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
const {
|
||||
conversations,
|
||||
selectedConversation,
|
||||
createConversation,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
refreshConversations,
|
||||
} = useConversations();
|
||||
|
||||
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
error,
|
||||
clearError,
|
||||
stalled,
|
||||
retryStalled,
|
||||
thinkingText,
|
||||
thinkingDurationMs,
|
||||
} = useChat({
|
||||
createConversation,
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
scrollToBottom,
|
||||
});
|
||||
|
||||
// Keep ref in sync for scrollToBottom behavior
|
||||
isLoadingRef.current = isLoading;
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(conversation: { title: string; id: string }) => {
|
||||
setShowConversations(false);
|
||||
navigate(`/c/${conversation.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleCreateNewConversation = useCallback(() => {
|
||||
setShowConversations(false);
|
||||
setSelectedConversation(conversation);
|
||||
const load = async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getConversation(conversation.id);
|
||||
setMessages(
|
||||
fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to load messages:", err);
|
||||
}
|
||||
};
|
||||
load();
|
||||
};
|
||||
navigate("/");
|
||||
}, [navigate]);
|
||||
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations();
|
||||
const parsed = fetched.map((c) => ({ id: c.id, title: c.name }));
|
||||
setConversations(parsed);
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewConversation = async () => {
|
||||
const newConv = await conversationService.createConversation();
|
||||
await loadConversations();
|
||||
setSelectedConversation({ title: newConv.name, id: newConv.id });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!selectedConversation) return;
|
||||
try {
|
||||
const conv = await conversationService.getConversation(selectedConversation.id);
|
||||
setSelectedConversation({ id: conv.id, title: conv.name });
|
||||
setMessages(conv.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load messages:", err);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [selectedConversation?.id]);
|
||||
|
||||
const handleQuestionSubmit = useCallback(async () => {
|
||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||
|
||||
let activeConversation = selectedConversation;
|
||||
if (!activeConversation) {
|
||||
const newConv = await conversationService.createConversation();
|
||||
activeConversation = { title: newConv.name, id: newConv.id };
|
||||
setSelectedConversation(activeConversation);
|
||||
setConversations((prev) => [activeConversation!, ...prev]);
|
||||
}
|
||||
|
||||
// Capture pending image before clearing state
|
||||
const imageFile = pendingImage;
|
||||
|
||||
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
||||
setMessages(currMessages);
|
||||
const handleQuestionSubmit = useCallback(() => {
|
||||
sendMessage(query, simbaMode);
|
||||
setQuery("");
|
||||
setPendingImage(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (simbaMode) {
|
||||
const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
// Upload image first if present
|
||||
let imageKey: string | undefined;
|
||||
if (imageFile) {
|
||||
const uploadResult = await conversationService.uploadImage(
|
||||
imageFile,
|
||||
activeConversation.id,
|
||||
);
|
||||
imageKey = uploadResult.image_key;
|
||||
|
||||
// Update the user message with the image key
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
// Find the last user message we just added
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
if (updated[i].speaker === "user") {
|
||||
updated[i] = { ...updated[i], image_key: imageKey };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
await conversationService.streamQuery(
|
||||
query,
|
||||
activeConversation.id,
|
||||
(event) => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (event.type === "tool_start") {
|
||||
const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
|
||||
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
|
||||
} else if (event.type === "response") {
|
||||
setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }]));
|
||||
} else if (event.type === "error") {
|
||||
console.error("Stream error:", event.message);
|
||||
}
|
||||
},
|
||||
abortController.signal,
|
||||
imageKey,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.log("Request was aborted");
|
||||
} else {
|
||||
console.error("Failed to send query:", error);
|
||||
if (error instanceof Error && error.message.includes("Session expired")) {
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
|
||||
}, [query, simbaMode, sendMessage]);
|
||||
|
||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||
kev.preventDefault();
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleQuestionSubmit();
|
||||
}
|
||||
}, [handleQuestionSubmit]);
|
||||
|
||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
|
||||
const handleClearImage = useCallback(() => setPendingImage(null), []);
|
||||
const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => deleteConversation(id),
|
||||
[deleteConversation],
|
||||
);
|
||||
|
||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
|
||||
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("access_token");
|
||||
@@ -241,9 +121,19 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
const sidebarProps = {
|
||||
conversations,
|
||||
onCreateNewConversation: handleCreateNewConversation,
|
||||
onSelectConversation: handleSelectConversation,
|
||||
onRenameConversation: renameConversation,
|
||||
onDeleteConversation: handleDeleteConversation,
|
||||
selectedId: selectedConversation?.id,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
||||
{/* ── Desktop Sidebar ─────────────────────────────── */}
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden md:flex md:flex-col",
|
||||
@@ -252,7 +142,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
/* Collapsed state */
|
||||
<div className="flex flex-col items-center py-4 gap-4 h-full">
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
@@ -267,66 +156,45 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Expanded state */
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
<h2
|
||||
className="text-lg font-bold text-cream tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
asksimba
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<PanelLeftClose size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversations */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowAdminPanel(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarContent
|
||||
{...sidebarProps}
|
||||
onShowAdmin={() => setShowAdminPanel(true)}
|
||||
onShowScheduler={() => setShowScheduler(true)}
|
||||
onLogout={handleLogout}
|
||||
onCollapse={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Admin Panel modal */}
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{/* Mobile drawer */}
|
||||
{showConversations && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<aside className="w-72 max-w-[80%] h-full bg-sidebar-bg shadow-xl animate-drawer-in">
|
||||
<SidebarContent
|
||||
{...sidebarProps}
|
||||
onShowAdmin={() => {
|
||||
setShowConversations(false);
|
||||
setShowAdminPanel(true);
|
||||
}}
|
||||
onShowScheduler={() => {
|
||||
setShowConversations(false);
|
||||
setShowScheduler(true);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onCloseDrawer={() => setShowConversations(false)}
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
className="flex-1 bg-charcoal/40 backdrop-blur-sm"
|
||||
onClick={() => setShowConversations(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||
|
||||
{/* ── Main chat area ──────────────────────────────── */}
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
{/* Mobile header */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
@@ -337,37 +205,17 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
asksimba
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={() => setShowConversations((v) => !v)}
|
||||
>
|
||||
{showConversations ? <X size={16} /> : <Menu size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={() => setShowConversations(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{messages.length === 0 ? (
|
||||
/* ── Empty / homepage state ── */
|
||||
{messages.length === 0 && !messagesLoading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||
{/* Mobile conversation drawer */}
|
||||
{showConversations && (
|
||||
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-amber-soft/20 rounded-full blur-3xl" />
|
||||
<img src={catIcon} alt="Simba" className="relative w-36 h-36" />
|
||||
@@ -384,57 +232,116 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
handleQueryChange={handleQueryChange}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleQuestionSubmit={handleQuestionSubmit}
|
||||
setSimbaMode={setSimbaMode}
|
||||
simbaMode={simbaMode}
|
||||
onToggleSimbaMode={handleToggleSimbaMode}
|
||||
isLoading={isLoading}
|
||||
onStop={stopGeneration}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={handleImageSelect}
|
||||
onClearImage={handleClearImage}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Active chat state ── */
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-2xl mx-auto flex flex-col gap-3">
|
||||
{/* Mobile conversation drawer */}
|
||||
{showConversations && (
|
||||
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
variant="light"
|
||||
/>
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-6">
|
||||
{messages.map((msg, index) => {
|
||||
// When the model has been thinking and content is now streaming,
|
||||
// render ThinkingBubble just before the streaming simba message
|
||||
// so it appears above the answer rather than below it.
|
||||
const showThinkingHere =
|
||||
streaming &&
|
||||
isLoading &&
|
||||
(thinking || thinkingText || thinkingDurationMs !== null) &&
|
||||
index === messages.length - 1 &&
|
||||
msg.speaker === "simba";
|
||||
|
||||
const bubble =
|
||||
msg.speaker === "tool" ? (
|
||||
<ToolBubble key={index} text={msg.text} />
|
||||
) : msg.speaker === "simba" ? (
|
||||
<AnswerBubble key={index} text={msg.text} />
|
||||
) : (
|
||||
<QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />
|
||||
);
|
||||
|
||||
if (showThinkingHere) {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<ThinkingBubble
|
||||
text={thinkingText}
|
||||
durationMs={thinkingDurationMs}
|
||||
active={thinking}
|
||||
/>
|
||||
{bubble}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return bubble;
|
||||
})}
|
||||
|
||||
{/* ThinkingBubble before content arrives (no streaming message yet) */}
|
||||
{isLoading && !streaming && (thinking || thinkingText || thinkingDurationMs !== null) && (
|
||||
<ThinkingBubble
|
||||
text={thinkingText}
|
||||
durationMs={thinkingDurationMs}
|
||||
active={thinking}
|
||||
/>
|
||||
)}
|
||||
|
||||
{((isLoading && !streaming && !thinking) || messagesLoading) && (
|
||||
<AnswerBubble text="" loading={true} />
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.speaker === "tool")
|
||||
return <ToolBubble key={index} text={msg.text} />;
|
||||
if (msg.speaker === "simba")
|
||||
return <AnswerBubble key={index} text={msg.text} />;
|
||||
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
|
||||
})}
|
||||
{stalled && (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600">
|
||||
<span>Simba didn't respond in time.</span>
|
||||
<button
|
||||
onClick={retryStalled}
|
||||
className="font-medium underline underline-offset-2 hover:text-red-800 transition-colors cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <AnswerBubble text="" loading={true} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-sand-light/40 bg-cream">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3">
|
||||
<MessageInput
|
||||
query={query}
|
||||
handleQueryChange={handleQueryChange}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleQuestionSubmit={handleQuestionSubmit}
|
||||
setSimbaMode={setSimbaMode}
|
||||
simbaMode={simbaMode}
|
||||
onToggleSimbaMode={handleToggleSimbaMode}
|
||||
isLoading={isLoading}
|
||||
onStop={stopGeneration}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={(file) => setPendingImage(file)}
|
||||
onClearImage={() => setPendingImage(null)}
|
||||
onImageSelect={handleImageSelect}
|
||||
onClearImage={handleClearImage}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
|
||||
type Conversation = {
|
||||
title: string;
|
||||
@@ -12,6 +11,8 @@ type ConversationProps = {
|
||||
conversations: Conversation[];
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateNewConversation: () => void;
|
||||
onRenameConversation?: (id: string, title: string) => void;
|
||||
onDeleteConversation?: (id: string) => void;
|
||||
selectedId?: string;
|
||||
variant?: "dark" | "light";
|
||||
};
|
||||
@@ -20,41 +21,57 @@ export const ConversationList = ({
|
||||
conversations,
|
||||
onSelectConversation,
|
||||
onCreateNewConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
selectedId,
|
||||
variant = "dark",
|
||||
}: ConversationProps) => {
|
||||
const [items, setItems] = useState(conversations);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isDark = variant === "dark";
|
||||
|
||||
// Close any open row menu when clicking elsewhere.
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
let fetched = await conversationService.getAllConversations();
|
||||
if (fetched.length === 0) {
|
||||
await conversationService.createConversation();
|
||||
fetched = await conversationService.getAllConversations();
|
||||
}
|
||||
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
if (!menuOpenId && !confirmDeleteId) return;
|
||||
const close = () => {
|
||||
setMenuOpenId(null);
|
||||
setConfirmDeleteId(null);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, [menuOpenId, confirmDeleteId]);
|
||||
|
||||
// Keep in sync when parent updates conversations
|
||||
useEffect(() => {
|
||||
setItems(conversations);
|
||||
}, [conversations]);
|
||||
if (editingId) inputRef.current?.focus();
|
||||
}, [editingId]);
|
||||
|
||||
const startRename = (conv: Conversation) => {
|
||||
setMenuOpenId(null);
|
||||
setEditingId(conv.id);
|
||||
setEditValue(conv.title);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingId && editValue.trim()) {
|
||||
onRenameConversation?.(editingId, editValue.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const hasActions = Boolean(onRenameConversation || onDeleteConversation);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* New thread button */}
|
||||
<button
|
||||
onClick={onCreateNewConversation}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 rounded-xl",
|
||||
"text-sm transition-all duration-150 cursor-pointer mb-1",
|
||||
variant === "dark"
|
||||
isDark
|
||||
? "text-cream/60 hover:text-cream hover:bg-white/8"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
@@ -63,27 +80,150 @@ export const ConversationList = ({
|
||||
<span>New thread</span>
|
||||
</button>
|
||||
|
||||
{/* Conversation items */}
|
||||
{items.map((conv) => {
|
||||
{conversations.map((conv) => {
|
||||
const isActive = conv.id === selectedId;
|
||||
const isEditing = editingId === conv.id;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-1 rounded-xl",
|
||||
isDark ? "bg-white/12" : "bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 bg-transparent px-1.5 py-1 text-sm rounded-lg",
|
||||
"focus:outline-none",
|
||||
isDark
|
||||
? "text-cream placeholder:text-cream/40"
|
||||
: "text-charcoal placeholder:text-warm-gray/50",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={commitRename}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors",
|
||||
isDark
|
||||
? "text-cream/60 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
|
||||
)}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors",
|
||||
isDark
|
||||
? "text-cream/60 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
|
||||
)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => onSelectConversation(conv)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 rounded-xl text-left",
|
||||
"text-sm truncate transition-all duration-150 cursor-pointer",
|
||||
variant === "dark"
|
||||
? isActive
|
||||
? "bg-white/12 text-cream font-medium"
|
||||
: "text-cream/60 hover:text-cream hover:bg-white/8"
|
||||
: isActive
|
||||
? "bg-cream-dark text-charcoal font-medium"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
<div key={conv.id} className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => onSelectConversation(conv)}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 px-3 py-2 rounded-xl text-left",
|
||||
"text-sm truncate transition-all duration-150 cursor-pointer",
|
||||
hasActions && "pr-8",
|
||||
isDark
|
||||
? isActive
|
||||
? "bg-white/12 text-cream font-medium"
|
||||
: "text-cream/60 hover:text-cream hover:bg-white/8"
|
||||
: isActive
|
||||
? "bg-cream-dark text-charcoal font-medium"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
{conv.title}
|
||||
</button>
|
||||
|
||||
{hasActions && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteId(null);
|
||||
setMenuOpenId((prev) => (prev === conv.id ? null : conv.id));
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1.5 w-6 h-6 rounded-lg flex items-center justify-center cursor-pointer transition-all",
|
||||
"opacity-0 group-hover:opacity-100 focus:opacity-100",
|
||||
menuOpenId === conv.id && "opacity-100",
|
||||
isDark
|
||||
? "text-cream/50 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-sand-light",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{conv.title}
|
||||
</button>
|
||||
|
||||
{menuOpenId === conv.id && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute right-1.5 top-9 z-20 w-36 rounded-xl p-1 shadow-lg",
|
||||
isDark
|
||||
? "bg-sidebar-bg border border-white/10"
|
||||
: "bg-warm-white border border-sand-light",
|
||||
)}
|
||||
>
|
||||
{onRenameConversation && (
|
||||
<button
|
||||
onClick={() => startRename(conv)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
isDark
|
||||
? "text-cream/70 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
)}
|
||||
{onDeleteConversation && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirmDeleteId === conv.id) {
|
||||
onDeleteConversation(conv.id);
|
||||
setMenuOpenId(null);
|
||||
setConfirmDeleteId(null);
|
||||
} else {
|
||||
setConfirmDeleteId(conv.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
"text-red-400 hover:bg-red-500/10",
|
||||
)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>
|
||||
{confirmDeleteId === conv.id ? "Confirm delete" : "Delete"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ConversationMenuProps = {
|
||||
conversations: Conversation[];
|
||||
};
|
||||
|
||||
export const ConversationMenu = ({ conversations }: ConversationMenuProps) => {
|
||||
return (
|
||||
<div className="absolute bg-white w-md rounded-md shadow-xl m-4 p-4">
|
||||
<p className="py-2 px-4 rounded-md w-full text-xl font-bold">askSimba!</p>
|
||||
{conversations.map((conversation) => (
|
||||
<p
|
||||
key={conversation.id}
|
||||
className="py-2 px-4 rounded-md hover:bg-stone-200 w-full text-xl font-bold cursor-pointer"
|
||||
>
|
||||
{conversation.title}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { useConversationBrowser } from "../hooks/useConversationBrowser";
|
||||
import type { Conversation } from "../hooks/useConversations";
|
||||
import { Input } from "./ui/input";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
export const ConversationsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
items,
|
||||
search,
|
||||
setSearch,
|
||||
hasMore,
|
||||
loading,
|
||||
loadMore,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
} = useConversationBrowser();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(conversation: Conversation) => navigate(`/c/${conversation.id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleCreateNew = useCallback(() => navigate("/"), [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-screen h-[100dvh] flex flex-col bg-cream overflow-hidden">
|
||||
<header className="flex items-center gap-3 px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<img src={catIcon} alt="Simba" className="w-10 h-10" />
|
||||
<h1
|
||||
className="text-base font-bold text-charcoal"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
All conversations
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 flex flex-col gap-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-gray/60"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search conversations..."
|
||||
className="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={items}
|
||||
onCreateNewConversation={handleCreateNew}
|
||||
onSelectConversation={handleSelect}
|
||||
onRenameConversation={renameConversation}
|
||||
onDeleteConversation={deleteConversation}
|
||||
variant="light"
|
||||
/>
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<p className="text-center text-sm text-warm-gray/70 py-6">
|
||||
{search ? "No conversations match your search." : "No conversations yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="self-center px-4 py-2 rounded-xl text-sm text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +1,19 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
import { oidcService } from "../api/oidcService";
|
||||
import catIcon from "../assets/cat.png";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useOIDCAuth } from "../hooks/useOIDCAuth";
|
||||
|
||||
type LoginScreenProps = {
|
||||
setAuthenticated: (isAuth: boolean) => void;
|
||||
};
|
||||
|
||||
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const callbackParams = oidcService.getCallbackParamsFromURL();
|
||||
if (callbackParams) {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
const result = await oidcService.handleCallback(
|
||||
callbackParams.code,
|
||||
callbackParams.state,
|
||||
);
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
localStorage.setItem("refresh_token", result.refresh_token);
|
||||
oidcService.clearCallbackParams();
|
||||
setAuthenticated(true);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error("OIDC callback error:", err);
|
||||
setError("Login failed. Please try again.");
|
||||
oidcService.clearCallbackParams();
|
||||
setIsLoggingIn(false);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const isValid = await userService.validateToken();
|
||||
if (isValid) setAuthenticated(true);
|
||||
setIsChecking(false);
|
||||
};
|
||||
initAuth();
|
||||
}, [setAuthenticated]);
|
||||
|
||||
const handleOIDCLogin = async () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setError("");
|
||||
const authUrl = await oidcService.initiateLogin();
|
||||
window.location.href = authUrl;
|
||||
} catch {
|
||||
setError("Failed to initiate login. Please try again.");
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
const { isChecking, isLoggingIn, error, handleLogin } = useOIDCAuth({
|
||||
setAuthenticated,
|
||||
});
|
||||
|
||||
if (isChecking || isLoggingIn) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||
{/* Subtle dot grid */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
||||
style={{
|
||||
@@ -85,7 +38,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background dot texture */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
||||
style={{
|
||||
@@ -94,12 +46,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Decorative background blobs */}
|
||||
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative w-full max-w-sm">
|
||||
{/* Branding */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
||||
@@ -120,7 +70,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-warm-white rounded-3xl border border-sand-light",
|
||||
@@ -138,7 +87,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleOIDCLogin}
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn}
|
||||
className={cn(
|
||||
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
|
||||
@@ -154,7 +103,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
||||
✦ meow ✦
|
||||
* meow *
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,160 +1,204 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
type MessageInputProps = {
|
||||
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
handleQuestionSubmit: () => void;
|
||||
setSimbaMode: (val: boolean) => void;
|
||||
simbaMode: boolean;
|
||||
onToggleSimbaMode: () => void;
|
||||
query: string;
|
||||
isLoading: boolean;
|
||||
onStop: () => void;
|
||||
pendingImage: File | null;
|
||||
onImageSelect: (file: File) => void;
|
||||
onClearImage: () => void;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export const MessageInput = React.memo(({
|
||||
query,
|
||||
handleKeyDown,
|
||||
handleQueryChange,
|
||||
handleQuestionSubmit,
|
||||
setSimbaMode,
|
||||
isLoading,
|
||||
pendingImage,
|
||||
onImageSelect,
|
||||
onClearImage,
|
||||
}: MessageInputProps) => {
|
||||
const [simbaMode, setLocalSimbaMode] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
export const MessageInput = React.memo(
|
||||
({
|
||||
query,
|
||||
handleKeyDown,
|
||||
handleQueryChange,
|
||||
handleQuestionSubmit,
|
||||
simbaMode,
|
||||
onToggleSimbaMode,
|
||||
isLoading,
|
||||
onStop,
|
||||
pendingImage,
|
||||
onImageSelect,
|
||||
onClearImage,
|
||||
autoFocus,
|
||||
}: MessageInputProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Create blob URL once per file, revoke on cleanup
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||
[pendingImage],
|
||||
);
|
||||
// Create blob URL once per file, revoke on cleanup
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||
[pendingImage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
// Auto-grow the textarea to fit its content (capped by max-height in CSS).
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [query]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onImageSelect(file);
|
||||
}
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const toggleSimbaMode = () => {
|
||||
const next = !simbaMode;
|
||||
setLocalSimbaMode(next);
|
||||
setSimbaMode(next);
|
||||
};
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isLoading) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
onImageSelect(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onImageSelect(file);
|
||||
}
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
};
|
||||
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
|
||||
|
||||
const canSend = !isLoading && (query.trim() || pendingImage);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
|
||||
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
|
||||
"focus-within:border-amber-soft/60",
|
||||
)}
|
||||
>
|
||||
{/* Image preview */}
|
||||
{pendingImage && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl!}
|
||||
alt="Pending upload"
|
||||
className="h-20 rounded-lg object-cover border border-sand"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearImage}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
|
||||
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
|
||||
"focus-within:border-amber-soft/60",
|
||||
)}
|
||||
>
|
||||
{/* Image preview */}
|
||||
{pendingImage && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl!}
|
||||
alt="Pending upload"
|
||||
className="h-20 rounded-lg object-cover border border-sand"
|
||||
/>
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
value={query}
|
||||
rows={2}
|
||||
placeholder="Ask Simba anything..."
|
||||
className="min-h-[60px] max-h-40 overflow-y-auto"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Simba mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearImage}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
|
||||
onClick={onToggleSimbaMode}
|
||||
className="flex items-center gap-2 group cursor-pointer select-none"
|
||||
>
|
||||
<X size={12} />
|
||||
<div className={cn("toggle-track", simbaMode && "checked")}>
|
||||
<div className="toggle-thumb" />
|
||||
</div>
|
||||
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
|
||||
simba mode
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Image attach button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
||||
isLoading
|
||||
? "text-warm-gray/40 cursor-not-allowed"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<ImagePlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
onChange={handleQueryChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={query}
|
||||
rows={2}
|
||||
placeholder="Ask Simba anything..."
|
||||
className="min-h-[60px] max-h-40"
|
||||
/>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Simba mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSimbaMode}
|
||||
className="flex items-center gap-2 group cursor-pointer select-none"
|
||||
>
|
||||
<div className={cn("toggle-track", simbaMode && "checked")}>
|
||||
<div className="toggle-thumb" />
|
||||
</div>
|
||||
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
|
||||
simba mode
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Image attach button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
|
||||
isLoading
|
||||
? "text-warm-gray/40 cursor-not-allowed"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<ImagePlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleQuestionSubmit}
|
||||
disabled={!canSend}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
"shadow-sm",
|
||||
!canSend
|
||||
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
|
||||
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
|
||||
{/* Send / Stop button */}
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label="Stop generating"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm",
|
||||
"bg-charcoal text-white transition-all duration-200 cursor-pointer",
|
||||
"hover:bg-charcoal/85 active:scale-95",
|
||||
)}
|
||||
>
|
||||
<Square size={13} strokeWidth={2.5} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleQuestionSubmit}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center",
|
||||
"transition-all duration-200 cursor-pointer shadow-sm",
|
||||
!canSend
|
||||
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
|
||||
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
|
||||
)}
|
||||
>
|
||||
<ArrowUp size={15} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<ArrowUp size={15} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
||||
|
||||
type QuestionBubbleProps = {
|
||||
text: string;
|
||||
image_key?: string | null;
|
||||
};
|
||||
|
||||
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image_key) return;
|
||||
conversationService
|
||||
.getPresignedImageUrl(image_key)
|
||||
.then(setImageUrl)
|
||||
.catch((err) => {
|
||||
console.error("Failed to load image:", err);
|
||||
setImageError(true);
|
||||
});
|
||||
}, [image_key]);
|
||||
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
||||
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end message-enter">
|
||||
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||
>
|
||||
{imageError && (
|
||||
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
|
||||
<span>🖼️</span>
|
||||
<span>Image failed to load</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState } from "react";
|
||||
import { X, Clock, Send, Trash2, XCircle, RotateCcw, Repeat, Bot } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Badge } from "./ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
import { useScheduledMessages } from "../hooks/useScheduledMessages";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
type CreateScheduledMessage,
|
||||
} from "../api/scheduledMessageService";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, "amber" | "default" | "destructive" | "muted"> = {
|
||||
pending: "amber",
|
||||
sent: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "muted",
|
||||
};
|
||||
|
||||
export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
const { messages, loading, refresh } = useScheduledMessages();
|
||||
const [channel, setChannel] = useState<"imessage" | "email">("imessage");
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [recurrence, setRecurrence] = useState<"none" | "daily" | "weekly" | "monthly">("none");
|
||||
const [useAgent, setUseAgent] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError("");
|
||||
if (!recipient || !content || !scheduledAt) {
|
||||
setError("Recipient, content, and scheduled time are required.");
|
||||
return;
|
||||
}
|
||||
if (channel === "email" && !subject) {
|
||||
setError("Subject is required for email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: CreateScheduledMessage = {
|
||||
recipient,
|
||||
channel,
|
||||
content,
|
||||
scheduled_at: new Date(scheduledAt).toISOString(),
|
||||
recurrence,
|
||||
use_agent: useAgent,
|
||||
};
|
||||
if (channel === "email") data.subject = subject;
|
||||
await scheduledMessageService.create(data);
|
||||
setRecipient("");
|
||||
setSubject("");
|
||||
setContent("");
|
||||
setScheduledAt("");
|
||||
setRecurrence("none");
|
||||
setUseAgent(false);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to schedule message");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await scheduledMessageService.update(id, { status: "cancelled" });
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await scheduledMessageService.remove(id);
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleRetry = async (id: string) => {
|
||||
try {
|
||||
const futureTime = new Date(Date.now() + 30_000).toISOString();
|
||||
await scheduledMessageService.update(id, { scheduled_at: futureTime });
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
|
||||
"w-full max-w-3xl mx-4 max-h-[85vh] flex flex-col",
|
||||
"border border-sand-light/60",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-amber-pale flex items-center justify-center">
|
||||
<Clock size={14} className="text-amber-glow" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-charcoal">
|
||||
Scheduled Messages
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||
{/* Create form */}
|
||||
<div className="px-6 py-5 border-b border-sand-light/60 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setChannel("imessage")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
channel === "imessage"
|
||||
? "bg-leaf-pale text-leaf-dark"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
iMessage
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel("email")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
channel === "email"
|
||||
? "bg-leaf-pale text-leaf-dark"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder={channel === "imessage" ? "+15551234567" : "user@example.com"}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={12} className="text-warm-gray" />
|
||||
<span className="text-xs text-warm-gray">Repeat:</span>
|
||||
{(["none", "daily", "weekly", "monthly"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRecurrence(r)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
recurrence === r
|
||||
? "bg-amber-pale text-amber-glow"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
{r === "none" ? "Once" : r.charAt(0).toUpperCase() + r.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setUseAgent(!useAgent)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer",
|
||||
useAgent
|
||||
? "bg-leaf-pale text-leaf-dark"
|
||||
: "bg-sand-light/40 text-warm-gray hover:text-charcoal",
|
||||
)}
|
||||
>
|
||||
<Bot size={12} />
|
||||
Ask Simba
|
||||
</button>
|
||||
<span className="text-xs text-warm-gray">
|
||||
{useAgent ? "Content is a prompt — Simba's response will be sent" : "Content sent as-is"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{channel === "email" && (
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Subject"
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={useAgent ? "Enter a prompt for Simba..." : "Message content..."}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-sand bg-cream-light px-3 py-2 text-sm text-charcoal placeholder:text-warm-gray/50 focus:outline-none focus:ring-2 focus:ring-leaf/30 resize-none"
|
||||
/>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<Button onClick={handleCreate} disabled={submitting} size="sm">
|
||||
<Send size={12} />
|
||||
{submitting ? "Scheduling..." : "Schedule"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
{loading ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
<div className="flex justify-center gap-1.5 mb-3">
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||
</div>
|
||||
Loading...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
No scheduled messages yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Content</TableHead>
|
||||
<TableHead>Scheduled</TableHead>
|
||||
<TableHead>Repeat</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.map((msg) => (
|
||||
<TableRow key={msg.id}>
|
||||
<TableCell className="capitalize text-xs">
|
||||
{msg.channel}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[140px]" title={msg.recipient}>
|
||||
{msg.recipient}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px]" title={msg.content}>
|
||||
<div className="flex items-center gap-1">
|
||||
{msg.use_agent && <Bot size={10} className="text-leaf-dark shrink-0" />}
|
||||
<span className="truncate">
|
||||
{msg.content.length > 60
|
||||
? msg.content.slice(0, 60) + "..."
|
||||
: msg.content}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-warm-gray">
|
||||
{new Date(msg.scheduled_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-warm-gray capitalize">
|
||||
{msg.recurrence === "none" ? "—" : msg.recurrence}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{msg.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleCancel(msg.id)}
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircle size={11} />
|
||||
</Button>
|
||||
)}
|
||||
{msg.status === "failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleRetry(msg.id)}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</Button>
|
||||
)}
|
||||
{(msg.status === "pending" || msg.status === "cancelled") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(msg.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Clock,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
Shield,
|
||||
ListTree,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import type { Conversation } from "../hooks/useConversations";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
type SidebarContentProps = {
|
||||
conversations: Conversation[];
|
||||
onCreateNewConversation: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onRenameConversation: (id: string, title: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
selectedId?: string;
|
||||
isAdmin: boolean;
|
||||
onShowAdmin: () => void;
|
||||
onShowScheduler: () => void;
|
||||
onLogout: () => void;
|
||||
/** Desktop: collapse the sidebar. Renders a PanelLeftClose button. */
|
||||
onCollapse?: () => void;
|
||||
/** Mobile: close the drawer. Renders an X button. */
|
||||
onCloseDrawer?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dark sidebar body shared by the desktop sidebar and the mobile drawer.
|
||||
* Shows the recent conversations, a link to the full list, and admin/account
|
||||
* actions. Exactly one of `onCollapse`/`onCloseDrawer` is expected per usage.
|
||||
*/
|
||||
export const SidebarContent = ({
|
||||
conversations,
|
||||
onCreateNewConversation,
|
||||
onSelectConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
selectedId,
|
||||
isAdmin,
|
||||
onShowAdmin,
|
||||
onShowScheduler,
|
||||
onLogout,
|
||||
onCollapse,
|
||||
onCloseDrawer,
|
||||
}: SidebarContentProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
<h2
|
||||
className="text-lg font-bold text-cream tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
asksimba
|
||||
</h2>
|
||||
</div>
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<PanelLeftClose size={15} />
|
||||
</button>
|
||||
)}
|
||||
{onCloseDrawer && (
|
||||
<button
|
||||
onClick={onCloseDrawer}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={onCreateNewConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRenameConversation={onRenameConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||
<Link
|
||||
to="/conversations"
|
||||
onClick={onCloseDrawer}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<ListTree size={14} />
|
||||
<span>See all conversations</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={onShowAdmin}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowScheduler}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Scheduler</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ThinkingBubbleProps = {
|
||||
text: string;
|
||||
durationMs: number | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export const ThinkingBubble = React.memo(
|
||||
({ text, durationMs, active }: ThinkingBubbleProps) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll thinking text as it streams in.
|
||||
useEffect(() => {
|
||||
if (active && textRef.current) {
|
||||
textRef.current.scrollTop = textRef.current.scrollHeight;
|
||||
}
|
||||
}, [text, active]);
|
||||
|
||||
// Collapse automatically when thinking finishes.
|
||||
useEffect(() => {
|
||||
if (durationMs !== null) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [durationMs]);
|
||||
|
||||
const label = active
|
||||
? "Thinking…"
|
||||
: durationMs !== null
|
||||
? `Thought for ${(durationMs / 1000).toFixed(1)}s`
|
||||
: "Thought";
|
||||
|
||||
return (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div className="w-full max-w-2xl">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2",
|
||||
"px-3 py-1.5 rounded-xl",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
"hover:bg-leaf-light/20 transition-colors cursor-pointer",
|
||||
active && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{text && (
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={cn(
|
||||
"text-leaf-dark/60 transition-transform duration-200 shrink-0",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && text && (
|
||||
<div
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"mt-1 px-3 py-2 rounded-xl max-h-48 overflow-y-auto",
|
||||
"bg-leaf-pale/40 border border-leaf-light/30",
|
||||
"text-xs text-leaf-dark/70 font-mono leading-relaxed whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,15 +1,19 @@
|
||||
import React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const ToolBubble = ({ text }: { text: string }) => (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
export const ToolBubble = React.memo(
|
||||
({ text, pulse = false }: { text: string; pulse?: boolean }) => (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ const badgeVariants = cva(
|
||||
default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
|
||||
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
|
||||
muted: "bg-sand-light/60 text-warm-gray border border-sand/40",
|
||||
destructive: "bg-red-50 text-red-600 border border-red-200/50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
ref?: React.Ref<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export const Textarea = ({ className, ...props }: TextareaProps) => {
|
||||
export const Textarea = ({ className, ref, ...props }: TextareaProps) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex w-full resize-none rounded-xl border-0 bg-transparent px-3 py-2.5",
|
||||
"text-sm text-charcoal placeholder:text-warm-gray/50",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
interface AuthContextType {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { userService, type AdminUserRecord } from "../api/userService";
|
||||
|
||||
export function useAdminUsers() {
|
||||
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
userService
|
||||
.adminListUsers()
|
||||
.then(setUsers)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const updateUser = (userId: string, updater: (u: AdminUserRecord) => AdminUserRecord) => {
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? updater(u) : u)));
|
||||
};
|
||||
|
||||
return { users, loading, updateUser };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
export function useAuthCheck() {
|
||||
const [isAuthenticated, setAuthenticated] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
setIsChecking(false);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await userService.getMe();
|
||||
setAuthenticated(true);
|
||||
setIsAdmin(me.is_admin);
|
||||
} catch {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
setAuthenticated(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
return { isAuthenticated, isChecking, isAdmin, setAuthenticated };
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
type Message = {
|
||||
text: string;
|
||||
speaker: "simba" | "user" | "tool";
|
||||
image_key?: string | null;
|
||||
};
|
||||
|
||||
const TOOL_MESSAGES: Record<string, string> = {
|
||||
simba_search: "Searching Simba's records...",
|
||||
web_search: "Searching the web...",
|
||||
get_current_date: "Checking today's date...",
|
||||
ynab_budget_summary: "Checking budget summary...",
|
||||
ynab_search_transactions: "Looking up transactions...",
|
||||
ynab_category_spending: "Analyzing category spending...",
|
||||
ynab_insights: "Generating budget insights...",
|
||||
obsidian_search_notes: "Searching notes...",
|
||||
obsidian_read_note: "Reading note...",
|
||||
obsidian_create_note: "Saving note...",
|
||||
obsidian_create_task: "Creating task...",
|
||||
journal_get_today: "Reading today's journal...",
|
||||
journal_get_tasks: "Getting tasks...",
|
||||
journal_add_task: "Adding task...",
|
||||
journal_complete_task: "Completing task...",
|
||||
};
|
||||
|
||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||
|
||||
type UseChatOptions = {
|
||||
createConversation: () => Promise<Conversation>;
|
||||
refreshConversations: () => Promise<void>;
|
||||
onSessionExpired: () => void;
|
||||
scrollToBottom: () => void;
|
||||
};
|
||||
|
||||
export function useChat({
|
||||
createConversation,
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
scrollToBottom,
|
||||
}: UseChatOptions) {
|
||||
// ChatScreen renders under a single catch-all route so it isn't remounted
|
||||
// when the first message navigates to /c/:id (which would abort the stream),
|
||||
// so the id comes from matching the URL rather than useParams.
|
||||
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stalled, setStalled] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [thinkingText, setThinkingText] = useState("");
|
||||
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null);
|
||||
|
||||
const stalledQueryRef = useRef<string | null>(null);
|
||||
const thinkingStartRef = useRef<number | null>(null);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
// True once tokens for the current assistant reply have started arriving, so
|
||||
// new content appends to the live bubble instead of creating another one.
|
||||
const streamingActiveRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Set to a conversation id we just created locally, so the route-change
|
||||
// loader below doesn't wipe the optimistic message + in-flight stream.
|
||||
const skipLoadForIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
// Intentionally do NOT abort on unmount — navigation away lets the
|
||||
// stream finish in the background so the response gets persisted.
|
||||
// stopGeneration() still aborts on explicit user cancel.
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateMessages = useCallback(
|
||||
(updater: Message[] | ((prev: Message[]) => Message[])) => {
|
||||
setMessages(updater);
|
||||
scrollToBottom();
|
||||
},
|
||||
[scrollToBottom],
|
||||
);
|
||||
|
||||
// Load messages whenever the active conversation (URL) changes. Clearing
|
||||
// immediately makes the switch feel instant instead of lingering on the old
|
||||
// thread until the fetch resolves.
|
||||
useEffect(() => {
|
||||
// Skip only for a conversation we just created locally (its optimistic
|
||||
// message + stream are already in state). Guard against the null sentinel
|
||||
// colliding with the "no conversation" (home) route.
|
||||
if (
|
||||
skipLoadForIdRef.current !== null &&
|
||||
skipLoadForIdRef.current === conversationId
|
||||
) {
|
||||
skipLoadForIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
setMessagesLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setMessages([]);
|
||||
setMessagesLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getConversation(conversationId);
|
||||
if (cancelled) return;
|
||||
setMessages(
|
||||
(fetched.messages ?? []).map((m) => ({
|
||||
text: m.text,
|
||||
speaker: m.speaker,
|
||||
image_key: m.image_key,
|
||||
})),
|
||||
);
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load messages:", err);
|
||||
setMessages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, scrollToBottom]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (query: string, simbaMode: boolean) => {
|
||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||
|
||||
let activeId = conversationId;
|
||||
if (!activeId) {
|
||||
const created = await createConversation();
|
||||
// Prevent the route-change loader from clearing what we're about to add.
|
||||
skipLoadForIdRef.current = created.id;
|
||||
activeId = created.id;
|
||||
}
|
||||
|
||||
const imageFile = pendingImage;
|
||||
|
||||
setError(null);
|
||||
setStalled(false);
|
||||
stalledQueryRef.current = null;
|
||||
setThinkingText("");
|
||||
setThinkingDurationMs(null);
|
||||
thinkingStartRef.current = null;
|
||||
streamingActiveRef.current = false;
|
||||
setStreaming(false);
|
||||
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||
setPendingImage(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (simbaMode) {
|
||||
const randomElement =
|
||||
simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: randomElement, speaker: "simba" }]),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
if (imageFile) {
|
||||
const uploadResult = await conversationService.uploadImage(
|
||||
imageFile,
|
||||
activeId,
|
||||
);
|
||||
imageKey = uploadResult.image_key;
|
||||
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
if (updated[i].speaker === "user") {
|
||||
updated[i] = { ...updated[i], image_key: imageKey };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
await conversationService.streamQuery(
|
||||
query,
|
||||
activeId,
|
||||
(event) => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (event.type === "thinking") {
|
||||
setThinking(true);
|
||||
if (thinkingStartRef.current === null) thinkingStartRef.current = Date.now();
|
||||
} else if (event.type === "thinking_delta") {
|
||||
setThinking(true);
|
||||
if (thinkingStartRef.current === null) thinkingStartRef.current = Date.now();
|
||||
setThinkingText((prev) => prev + event.delta);
|
||||
} else if (event.type === "tool_start") {
|
||||
// Any text after a tool call belongs in a fresh bubble.
|
||||
streamingActiveRef.current = false;
|
||||
setThinking(false);
|
||||
const friendly =
|
||||
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: friendly, speaker: "tool" }]),
|
||||
);
|
||||
} else if (event.type === "content") {
|
||||
setThinking(false);
|
||||
if (thinkingStartRef.current !== null && thinkingDurationMs === null) {
|
||||
setThinkingDurationMs(Date.now() - thinkingStartRef.current);
|
||||
}
|
||||
if (streamingActiveRef.current) {
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
text: last.text + event.delta,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
streamingActiveRef.current = true;
|
||||
setStreaming(true);
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: event.delta, speaker: "simba" }]),
|
||||
);
|
||||
}
|
||||
} else if (event.type === "response") {
|
||||
// Reconcile the streamed bubble with the authoritative (persisted)
|
||||
// text; if nothing streamed, append it as a new message.
|
||||
if (streamingActiveRef.current) {
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
text: event.message,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
streamingActiveRef.current = false;
|
||||
} else {
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: event.message, speaker: "simba" }]),
|
||||
);
|
||||
}
|
||||
} else if (event.type === "error") {
|
||||
console.error("Stream error:", event.message);
|
||||
if (!isMountedRef.current) return;
|
||||
if (event.stalled) {
|
||||
stalledQueryRef.current = query;
|
||||
setStalled(true);
|
||||
} else {
|
||||
setError("Simba ran into a problem answering that.");
|
||||
}
|
||||
}
|
||||
},
|
||||
abortController.signal,
|
||||
imageKey,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled — not an error to surface.
|
||||
} else {
|
||||
console.error("Failed to send query:", err);
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Session expired")
|
||||
) {
|
||||
onSessionExpired();
|
||||
} else if (isMountedRef.current) {
|
||||
setError("Couldn't reach Simba. Check your connection and try again.");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
setStreaming(false);
|
||||
setThinking(false);
|
||||
// Refresh after every send so the just-used conversation floats to
|
||||
// the top of the sidebar (sorted by last activity) and picks up the
|
||||
// auto-generated title on the first message.
|
||||
refreshConversations();
|
||||
}
|
||||
streamingActiveRef.current = false;
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[
|
||||
pendingImage,
|
||||
isLoading,
|
||||
conversationId,
|
||||
createConversation,
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
updateMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const retryStalled = useCallback(() => {
|
||||
const q = stalledQueryRef.current;
|
||||
if (!q) return;
|
||||
setStalled(false);
|
||||
stalledQueryRef.current = null;
|
||||
sendMessage(q, false);
|
||||
}, [sendMessage]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages: updateMessages,
|
||||
isLoading,
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
error,
|
||||
clearError,
|
||||
stalled,
|
||||
retryStalled,
|
||||
thinkingText,
|
||||
thinkingDurationMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Data source for the dedicated /conversations page: server-side search plus
|
||||
* "Load more" pagination. Detects whether more rows exist by asking for one
|
||||
* extra item beyond the page size.
|
||||
*/
|
||||
export function useConversationBrowser() {
|
||||
const [items, setItems] = useState<Conversation[]>([]);
|
||||
const [search, setSearchValue] = useState("");
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (offset: number, searchTerm: string) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
limit: PAGE_SIZE + 1,
|
||||
offset,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const page = fetched
|
||||
.slice(0, PAGE_SIZE)
|
||||
.map((c) => ({ id: c.id, title: c.name }));
|
||||
setHasMore(fetched.length > PAGE_SIZE);
|
||||
setItems((prev) => (offset === 0 ? page : [...prev, ...page]));
|
||||
} catch (err) {
|
||||
if (requestId === requestIdRef.current) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Debounce search changes; reset to the first page on each new term.
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => fetchPage(0, search), 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [search, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
fetchPage(items.length, search);
|
||||
}, [loading, hasMore, items.length, search, fetchPage]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
const previous = items;
|
||||
setItems((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
|
||||
);
|
||||
try {
|
||||
await conversationService.renameConversation(id, trimmed);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename conversation:", err);
|
||||
setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string) => {
|
||||
const previous = items;
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
try {
|
||||
await conversationService.deleteConversation(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete conversation:", err);
|
||||
setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
search,
|
||||
setSearch: setSearchValue,
|
||||
hasMore,
|
||||
loading,
|
||||
loadMore,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useMatch, useNavigate } from "react-router-dom";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
|
||||
export type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
// The sidebar/drawer only shows the most recent conversations; the full list
|
||||
// lives on the dedicated /conversations page.
|
||||
const SIDEBAR_LIMIT = 10;
|
||||
|
||||
export function useConversations() {
|
||||
const navigate = useNavigate();
|
||||
// ChatScreen now renders under a single catch-all route, so useParams no
|
||||
// longer captures the id. Derive it from the URL directly instead.
|
||||
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
limit: SIDEBAR_LIMIT,
|
||||
});
|
||||
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshConversations();
|
||||
}, [refreshConversations]);
|
||||
|
||||
// The active conversation is derived from the URL. Fall back to a minimal
|
||||
// object while the list is still loading (e.g. on a deep-link refresh).
|
||||
const selectedConversation = useMemo<Conversation | null>(() => {
|
||||
if (!conversationId) return null;
|
||||
return (
|
||||
conversations.find((c) => c.id === conversationId) ?? {
|
||||
id: conversationId,
|
||||
title: "",
|
||||
}
|
||||
);
|
||||
}, [conversationId, conversations]);
|
||||
|
||||
const createConversation = useCallback(async (): Promise<Conversation> => {
|
||||
const newConv = await conversationService.createConversation();
|
||||
const conversation = { title: newConv.name, id: newConv.id };
|
||||
setConversations((prev) => [conversation, ...prev]);
|
||||
navigate(`/c/${conversation.id}`);
|
||||
return conversation;
|
||||
}, [navigate]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
// Optimistic update, roll back on failure. selectedConversation is
|
||||
// derived from `conversations`, so it updates automatically.
|
||||
const previous = conversations;
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
|
||||
);
|
||||
try {
|
||||
await conversationService.renameConversation(id, trimmed);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename conversation:", err);
|
||||
setConversations(previous);
|
||||
}
|
||||
},
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
const previous = conversations;
|
||||
const wasActive = conversationId === id;
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
// Navigating home clears the message view via the route change.
|
||||
if (wasActive) navigate("/");
|
||||
try {
|
||||
await conversationService.deleteConversation(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete conversation:", err);
|
||||
setConversations(previous);
|
||||
}
|
||||
},
|
||||
[conversations, conversationId, navigate],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations,
|
||||
selectedConversation,
|
||||
createConversation,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
refreshConversations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { userService } from "../api/userService";
|
||||
import { oidcService } from "../api/oidcService";
|
||||
|
||||
type UseOIDCAuthOptions = {
|
||||
setAuthenticated: (isAuth: boolean) => void;
|
||||
};
|
||||
|
||||
export function useOIDCAuth({ setAuthenticated }: UseOIDCAuthOptions) {
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const callbackParams = oidcService.getCallbackParamsFromURL();
|
||||
if (callbackParams) {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
const result = await oidcService.handleCallback(
|
||||
callbackParams.code,
|
||||
callbackParams.state,
|
||||
);
|
||||
localStorage.setItem("access_token", result.access_token);
|
||||
localStorage.setItem("refresh_token", result.refresh_token);
|
||||
oidcService.clearCallbackParams();
|
||||
setAuthenticated(true);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error("OIDC callback error:", err);
|
||||
setError("Login failed. Please try again.");
|
||||
oidcService.clearCallbackParams();
|
||||
setIsLoggingIn(false);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const isValid = await userService.validateToken();
|
||||
if (isValid) setAuthenticated(true);
|
||||
setIsChecking(false);
|
||||
};
|
||||
initAuth();
|
||||
}, [setAuthenticated]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setError("");
|
||||
const authUrl = await oidcService.initiateLogin();
|
||||
window.location.href = authUrl;
|
||||
} catch {
|
||||
setError("Failed to initiate login. Please try again.");
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isChecking, isLoggingIn, error, handleLogin };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
|
||||
const urlCache = new Map<string, string>();
|
||||
|
||||
export function usePresignedUrl(imageKey: string | null | undefined) {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(
|
||||
imageKey ? (urlCache.get(imageKey) ?? null) : null,
|
||||
);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageKey) return;
|
||||
|
||||
const cached = urlCache.get(imageKey);
|
||||
if (cached) {
|
||||
setImageUrl(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
conversationService
|
||||
.getPresignedImageUrl(imageKey)
|
||||
.then((url) => {
|
||||
urlCache.set(imageKey, url);
|
||||
setImageUrl(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load image:", err);
|
||||
setImageError(true);
|
||||
});
|
||||
}, [imageKey]);
|
||||
|
||||
return { imageUrl, imageError };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
type ScheduledMessage,
|
||||
} from "../api/scheduledMessageService";
|
||||
|
||||
export function useScheduledMessages() {
|
||||
const [messages, setMessages] = useState<ScheduledMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
scheduledMessageService
|
||||
.list()
|
||||
.then(setMessages)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { messages, loading, refresh };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
@@ -7,7 +8,9 @@ if (rootEl) {
|
||||
const root = ReactDOM.createRoot(rootEl);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -598,6 +598,11 @@ comma-separated-tokens@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
|
||||
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
|
||||
|
||||
cookie@^1.0.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
|
||||
integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
|
||||
|
||||
core-js@~3.47.0:
|
||||
version "3.47.0"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
|
||||
@@ -695,6 +700,11 @@ es-set-tostringtag@^2.1.0:
|
||||
has-tostringtag "^1.0.2"
|
||||
hasown "^2.0.2"
|
||||
|
||||
escape-string-regexp@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
|
||||
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
|
||||
|
||||
estree-util-is-identifier-name@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
|
||||
@@ -1011,6 +1021,11 @@ magic-string@^0.30.21:
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.5.5"
|
||||
|
||||
markdown-table@^3.0.0:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a"
|
||||
integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==
|
||||
|
||||
marked@^16.3.0:
|
||||
version "16.4.2"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3"
|
||||
@@ -1021,6 +1036,16 @@ math-intrinsics@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
mdast-util-find-and-replace@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df"
|
||||
integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
escape-string-regexp "^5.0.0"
|
||||
unist-util-is "^6.0.0"
|
||||
unist-util-visit-parents "^6.0.0"
|
||||
|
||||
mdast-util-from-markdown@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7"
|
||||
@@ -1039,6 +1064,71 @@ mdast-util-from-markdown@^2.0.0:
|
||||
micromark-util-types "^2.0.0"
|
||||
unist-util-stringify-position "^4.0.0"
|
||||
|
||||
mdast-util-gfm-autolink-literal@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5"
|
||||
integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
ccount "^2.0.0"
|
||||
devlop "^1.0.0"
|
||||
mdast-util-find-and-replace "^3.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
|
||||
mdast-util-gfm-footnote@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403"
|
||||
integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.1.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
micromark-util-normalize-identifier "^2.0.0"
|
||||
|
||||
mdast-util-gfm-strikethrough@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16"
|
||||
integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-gfm-table@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38"
|
||||
integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
markdown-table "^3.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-gfm-task-list-item@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936"
|
||||
integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-gfm@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751"
|
||||
integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==
|
||||
dependencies:
|
||||
mdast-util-from-markdown "^2.0.0"
|
||||
mdast-util-gfm-autolink-literal "^2.0.0"
|
||||
mdast-util-gfm-footnote "^2.0.0"
|
||||
mdast-util-gfm-strikethrough "^2.0.0"
|
||||
mdast-util-gfm-table "^2.0.0"
|
||||
mdast-util-gfm-task-list-item "^2.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
|
||||
mdast-util-mdx-expression@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
|
||||
@@ -1153,6 +1243,85 @@ micromark-core-commonmark@^2.0.0:
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-autolink-literal@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935"
|
||||
integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==
|
||||
dependencies:
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-sanitize-uri "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-footnote@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750"
|
||||
integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-core-commonmark "^2.0.0"
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-normalize-identifier "^2.0.0"
|
||||
micromark-util-sanitize-uri "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-strikethrough@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923"
|
||||
integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-util-chunked "^2.0.0"
|
||||
micromark-util-classify-character "^2.0.0"
|
||||
micromark-util-resolve-all "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-table@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b"
|
||||
integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-tagfilter@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57"
|
||||
integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==
|
||||
dependencies:
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm-task-list-item@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c"
|
||||
integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==
|
||||
dependencies:
|
||||
devlop "^1.0.0"
|
||||
micromark-factory-space "^2.0.0"
|
||||
micromark-util-character "^2.0.0"
|
||||
micromark-util-symbol "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-extension-gfm@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b"
|
||||
integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==
|
||||
dependencies:
|
||||
micromark-extension-gfm-autolink-literal "^2.0.0"
|
||||
micromark-extension-gfm-footnote "^2.0.0"
|
||||
micromark-extension-gfm-strikethrough "^2.0.0"
|
||||
micromark-extension-gfm-table "^2.0.0"
|
||||
micromark-extension-gfm-tagfilter "^2.0.0"
|
||||
micromark-extension-gfm-task-list-item "^2.0.0"
|
||||
micromark-util-combine-extensions "^2.0.0"
|
||||
micromark-util-types "^2.0.0"
|
||||
|
||||
micromark-factory-destination@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
|
||||
@@ -1464,6 +1633,21 @@ react-refresh@^0.18.0:
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062"
|
||||
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
|
||||
|
||||
react-router-dom@^7.18.3:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.3.tgz#0688c91b4b376c8b0dcaf0a0064975d52a32e0f7"
|
||||
integrity sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==
|
||||
dependencies:
|
||||
react-router "7.18.3"
|
||||
|
||||
react-router@7.18.3:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a"
|
||||
integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
|
||||
react@^19.1.1:
|
||||
version "19.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
|
||||
@@ -1485,6 +1669,18 @@ readdirp@~3.6.0:
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
remark-gfm@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b"
|
||||
integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
mdast-util-gfm "^3.0.0"
|
||||
micromark-extension-gfm "^3.0.0"
|
||||
remark-parse "^11.0.0"
|
||||
remark-stringify "^11.0.0"
|
||||
unified "^11.0.0"
|
||||
|
||||
remark-parse@^11.0.0:
|
||||
version "11.0.0"
|
||||
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
|
||||
@@ -1506,6 +1702,15 @@ remark-rehype@^11.0.0:
|
||||
unified "^11.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
remark-stringify@^11.0.0:
|
||||
version "11.0.0"
|
||||
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3"
|
||||
integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==
|
||||
dependencies:
|
||||
"@types/mdast" "^4.0.0"
|
||||
mdast-util-to-markdown "^2.0.0"
|
||||
unified "^11.0.0"
|
||||
|
||||
safe-buffer@~5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
@@ -1521,6 +1726,11 @@ semver@^7.5.3:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
|
||||
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
|
||||
|
||||
set-cookie-parser@^2.6.0:
|
||||
version "2.7.2"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
|
||||
integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
|
||||
|
||||
simple-update-notifier@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb"
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Zero-downtime blue-green deploy.
|
||||
#
|
||||
# Builds the inactive color, starts it, waits for its healthcheck to pass,
|
||||
# then gracefully stops the old color. Caddy health-checks both ports
|
||||
# (8080 = blue, 8081 = green) and routes traffic to whichever is healthy,
|
||||
# so the swap is invisible to clients.
|
||||
#
|
||||
# Caveats:
|
||||
# - Migrations run on the new container's startup while the old one still
|
||||
# serves, so they must be backward-compatible (additive changes only;
|
||||
# destructive changes need a two-step deploy).
|
||||
# - During the brief overlap both containers run pull-only Obsidian sync
|
||||
# against the shared ./obvault mount.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
running_services=$(docker compose ps --status running --services)
|
||||
|
||||
if echo "$running_services" | grep -qx "raggr-blue"; then
|
||||
OLD=blue NEW=green
|
||||
elif echo "$running_services" | grep -qx "raggr-green"; then
|
||||
OLD=green NEW=blue
|
||||
else
|
||||
OLD="" NEW=blue
|
||||
fi
|
||||
|
||||
echo "==> Active: ${OLD:-none} | Deploying: $NEW"
|
||||
|
||||
echo "==> Building raggr-$NEW..."
|
||||
docker compose --profile "$NEW" build "raggr-$NEW"
|
||||
|
||||
echo "==> Starting raggr-$NEW and waiting for healthy..."
|
||||
if ! docker compose --profile "$NEW" up -d --wait "raggr-$NEW"; then
|
||||
echo "!! raggr-$NEW failed to become healthy${OLD:+; raggr-$OLD is still serving}" >&2
|
||||
docker compose --profile "$NEW" logs --tail 50 "raggr-$NEW" >&2 || true
|
||||
docker compose --profile "$NEW" stop -t 5 "raggr-$NEW" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$OLD" ]; then
|
||||
echo "==> raggr-$NEW healthy; stopping raggr-$OLD (30s grace for in-flight streams)..."
|
||||
docker compose --profile "$OLD" stop -t 30 "raggr-$OLD"
|
||||
docker compose --profile "$OLD" rm -f "raggr-$OLD"
|
||||
fi
|
||||
|
||||
echo "==> Deploy complete: raggr-$NEW is live"
|
||||
@@ -0,0 +1,62 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "google-auth-oauthlib>=1.0.0",
|
||||
# ]
|
||||
# ///
|
||||
"""One-time OAuth2 setup to generate token.json for Google Calendar and Gmail.
|
||||
|
||||
Usage:
|
||||
uv run scripts/google_auth.py [--credentials credentials.json] [--token token.json]
|
||||
|
||||
Requires credentials.json downloaded from Google Cloud Console
|
||||
(APIs & Services → Credentials → OAuth 2.0 Client ID → Desktop app).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Authorize Google APIs for raggr")
|
||||
parser.add_argument(
|
||||
"--credentials",
|
||||
default="credentials.json",
|
||||
help="Path to OAuth2 credentials JSON from Google Cloud Console",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default="token.json",
|
||||
help="Output path for the generated token file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
creds_path = Path(args.credentials)
|
||||
if not creds_path.exists():
|
||||
print(f"Error: credentials file not found: {creds_path}")
|
||||
print(
|
||||
"Download it from Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
token_path = Path(args.token)
|
||||
token_path.write_text(creds.to_json())
|
||||
print(f"Token saved to {token_path}")
|
||||
print(
|
||||
f"Mount this file into Docker: - ./{token_path}:/app/config/google-token.json:ro"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Link an iMessage phone number to an existing user account.
|
||||
|
||||
Usage:
|
||||
python scripts/link_imessage.py <username_or_email> <phone_number>
|
||||
python scripts/link_imessage.py ryan +15551234567
|
||||
|
||||
Run inside Docker:
|
||||
docker compose exec raggr python scripts/link_imessage.py ryan +15551234567
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.users.models import User
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite://database/raggr.db")
|
||||
|
||||
|
||||
async def link_imessage(identifier: str, phone_number: str):
|
||||
await Tortoise.init(
|
||||
db_url=DATABASE_URL,
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
user = await User.filter(username=identifier).first()
|
||||
if not user:
|
||||
user = await User.filter(email=identifier).first()
|
||||
if not user:
|
||||
print(f"Error: No user found with username or email '{identifier}'")
|
||||
return False
|
||||
|
||||
conflict = (
|
||||
await User.filter(imessage_number=phone_number).exclude(id=user.id).first()
|
||||
)
|
||||
if conflict:
|
||||
print(
|
||||
f"Error: {phone_number} is already linked to user '{conflict.username}'"
|
||||
)
|
||||
return False
|
||||
|
||||
user.imessage_number = phone_number
|
||||
await user.save()
|
||||
print(f"Linked {phone_number} to user '{user.username}' ({user.email})")
|
||||
return True
|
||||
|
||||
finally:
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(
|
||||
"Usage: python scripts/link_imessage.py <username_or_email> <phone_number>"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
success = asyncio.run(link_imessage(sys.argv[1], sys.argv[2]))
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Manage Beli profiles and restaurant data.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/manage_beli.py add <username> <beli_email> <beli_password>
|
||||
uv run python scripts/manage_beli.py list
|
||||
uv run python scripts/manage_beli.py refresh <profile_id>
|
||||
uv run python scripts/manage_beli.py refresh-all
|
||||
uv run python scripts/manage_beli.py remove <profile_id>
|
||||
|
||||
Examples:
|
||||
uv run python scripts/manage_beli.py add ryan me@gmail.com mybelipass
|
||||
uv run python scripts/manage_beli.py list
|
||||
uv run python scripts/manage_beli.py refresh 3f2a1b...
|
||||
uv run python scripts/manage_beli.py refresh-all
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from tortoise import Tortoise
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgres://raggr:changeme@localhost:5432/raggr"
|
||||
)
|
||||
|
||||
TORTOISE_CONFIG = {
|
||||
"connections": {"default": DATABASE_URL},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.beli.models",
|
||||
"blueprints.email.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.scheduled_messages.models",
|
||||
"blueprints.cron.models",
|
||||
],
|
||||
"default_connection": "default",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _init():
|
||||
await Tortoise.init(config=TORTOISE_CONFIG)
|
||||
|
||||
|
||||
async def _close():
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
async def cmd_add(username: str, beli_email: str, beli_password: str):
|
||||
from blueprints.beli.logic import MAX_PROFILES_PER_USER
|
||||
from blueprints.beli.models import BeliProfile
|
||||
from blueprints.users.models import User
|
||||
from utils.beli_service import authenticate
|
||||
|
||||
user = await User.get_or_none(username=username)
|
||||
if not user:
|
||||
print(f"Error: no user with username '{username}'")
|
||||
return False
|
||||
|
||||
count = await BeliProfile.filter(user=user).count()
|
||||
if count >= MAX_PROFILES_PER_USER:
|
||||
print(
|
||||
f"Error: user already has {MAX_PROFILES_PER_USER} Beli profiles (maximum)"
|
||||
)
|
||||
return False
|
||||
|
||||
if await BeliProfile.exists(user=user, beli_email=beli_email):
|
||||
print(f"Error: profile for '{beli_email}' already exists for this user")
|
||||
return False
|
||||
|
||||
print(f"Authenticating with Beli as {beli_email}...")
|
||||
try:
|
||||
session = await authenticate(beli_email, beli_password)
|
||||
except Exception as e:
|
||||
print(f"Error: Beli authentication failed — {e}")
|
||||
return False
|
||||
|
||||
profile = await BeliProfile.create(
|
||||
user=user,
|
||||
beli_email=beli_email,
|
||||
beli_password=beli_password,
|
||||
beli_user_id=session["user_id"],
|
||||
beli_refresh_token=session["refresh_token"],
|
||||
enabled=True,
|
||||
)
|
||||
print(f"Profile created: {profile.id}")
|
||||
print(f" Beli user ID : {profile.beli_user_id}")
|
||||
print(f" Beli email : {profile.beli_email}")
|
||||
print(f" Owner : {username}")
|
||||
return True
|
||||
|
||||
|
||||
async def cmd_list():
|
||||
from blueprints.beli.models import BeliProfile, BeliRestaurant
|
||||
|
||||
profiles = await BeliProfile.all().prefetch_related("user").order_by("created_at")
|
||||
if not profiles:
|
||||
print("No Beli profiles found.")
|
||||
return
|
||||
|
||||
for p in profiles:
|
||||
count = await BeliRestaurant.filter(profile=p).count()
|
||||
refreshed = (
|
||||
p.last_refreshed_at.strftime("%Y-%m-%d %H:%M UTC")
|
||||
if p.last_refreshed_at
|
||||
else "never"
|
||||
)
|
||||
status = "enabled" if p.enabled else "DISABLED"
|
||||
print(f"{p.id} [{status}]")
|
||||
print(f" Owner : {p.user.email}")
|
||||
print(f" Beli : {p.beli_email} (user_id={p.beli_user_id})")
|
||||
print(f" Restaurants: {count} | Last refresh: {refreshed}")
|
||||
if p.last_error:
|
||||
print(f" Last error : {p.last_error}")
|
||||
print()
|
||||
|
||||
|
||||
async def cmd_refresh(profile_id: str):
|
||||
from blueprints.beli.logic import refresh_profile
|
||||
from blueprints.beli.models import BeliProfile
|
||||
|
||||
profile = await BeliProfile.get_or_none(id=profile_id)
|
||||
if not profile:
|
||||
print(f"Error: no profile with id '{profile_id}'")
|
||||
return False
|
||||
|
||||
print(f"Refreshing profile {profile_id} ({profile.beli_email})...")
|
||||
result = await refresh_profile(profile_id)
|
||||
if result.get("ok"):
|
||||
print(f"Done — {result['upserted']} restaurants upserted")
|
||||
elif result.get("skipped"):
|
||||
print("Skipped (profile disabled)")
|
||||
else:
|
||||
print(f"Failed: {result.get('error')}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def cmd_refresh_all():
|
||||
from blueprints.beli.models import BeliProfile
|
||||
from blueprints.beli.logic import refresh_profile
|
||||
|
||||
profiles = (
|
||||
await BeliProfile.filter(enabled=True).order_by("last_refreshed_at").all()
|
||||
)
|
||||
if not profiles:
|
||||
print("No enabled Beli profiles found.")
|
||||
return
|
||||
|
||||
print(f"Refreshing {len(profiles)} profile(s)...")
|
||||
for p in profiles:
|
||||
print(f"\n {p.beli_email} ({p.id})")
|
||||
result = await refresh_profile(str(p.id))
|
||||
if result.get("ok"):
|
||||
print(f" → {result['upserted']} restaurants upserted")
|
||||
else:
|
||||
print(f" → failed: {result.get('error')}")
|
||||
|
||||
|
||||
async def cmd_remove(profile_id: str):
|
||||
from blueprints.beli.models import BeliProfile, BeliRestaurant
|
||||
|
||||
profile = await BeliProfile.get_or_none(id=profile_id)
|
||||
if not profile:
|
||||
print(f"Error: no profile with id '{profile_id}'")
|
||||
return False
|
||||
|
||||
deleted = await BeliRestaurant.filter(profile=profile).count()
|
||||
await BeliRestaurant.filter(profile=profile).delete()
|
||||
await profile.delete()
|
||||
print(
|
||||
f"Removed profile {profile_id} ({profile.beli_email}) and {deleted} restaurants"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def usage():
|
||||
print(__doc__)
|
||||
|
||||
|
||||
async def main():
|
||||
if len(sys.argv) < 2:
|
||||
usage()
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
await _init()
|
||||
try:
|
||||
if cmd == "add":
|
||||
if len(sys.argv) != 5:
|
||||
print(
|
||||
"Usage: uv run python scripts/manage_beli.py add <username> <beli_email> <beli_password>"
|
||||
)
|
||||
sys.exit(1)
|
||||
ok = await cmd_add(sys.argv[2], sys.argv[3], sys.argv[4])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
elif cmd == "list":
|
||||
await cmd_list()
|
||||
|
||||
elif cmd == "refresh":
|
||||
if len(sys.argv) != 3:
|
||||
print(
|
||||
"Usage: uv run python scripts/manage_beli.py refresh <profile_id>"
|
||||
)
|
||||
sys.exit(1)
|
||||
ok = await cmd_refresh(sys.argv[2])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
elif cmd == "refresh-all":
|
||||
await cmd_refresh_all()
|
||||
|
||||
elif cmd == "remove":
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: uv run python scripts/manage_beli.py remove <profile_id>")
|
||||
sys.exit(1)
|
||||
ok = await cmd_remove(sys.argv[2])
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
usage()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await _close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -6,19 +6,19 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from blueprints.rag.logic import (
|
||||
delete_all_documents,
|
||||
get_vector_store_stats,
|
||||
index_documents,
|
||||
list_all_documents,
|
||||
vector_store,
|
||||
)
|
||||
|
||||
|
||||
def stats():
|
||||
"""Show vector store statistics."""
|
||||
stats = get_vector_store_stats()
|
||||
s = get_vector_store_stats()
|
||||
print("=== Vector Store Statistics ===")
|
||||
print(f"Collection: {stats['collection_name']}")
|
||||
print(f"Total Documents: {stats['total_documents']}")
|
||||
print(f"Collection: {s['collection_name']}")
|
||||
print(f"Total Documents: {s['total_documents']}")
|
||||
|
||||
|
||||
async def index():
|
||||
@@ -26,23 +26,15 @@ async def index():
|
||||
print("Starting indexing process...")
|
||||
print("Fetching documents from Paperless-NGX...")
|
||||
await index_documents()
|
||||
print("✓ Indexing complete!")
|
||||
print("Indexing complete!")
|
||||
stats()
|
||||
|
||||
|
||||
async def reindex():
|
||||
"""Clear and reindex all documents."""
|
||||
print("Clearing existing documents...")
|
||||
collection = vector_store._collection
|
||||
all_docs = collection.get()
|
||||
|
||||
if all_docs["ids"]:
|
||||
print(f"Deleting {len(all_docs['ids'])} existing documents...")
|
||||
collection.delete(ids=all_docs["ids"])
|
||||
print("✓ Cleared")
|
||||
else:
|
||||
print("Collection is already empty")
|
||||
|
||||
delete_all_documents()
|
||||
print("Cleared")
|
||||
await index()
|
||||
|
||||
|
||||
@@ -113,7 +105,7 @@ Examples:
|
||||
print("\n\nOperation cancelled by user")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}", file=sys.stderr)
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Generate a new aerich migration.
|
||||
# If a raggr container is running, exec into it.
|
||||
# Otherwise, spin up the local postgres service and run aerich from the venv.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Load .env so POSTGRES_* vars are available locally
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-raggr}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-raggr}"
|
||||
LOCAL_DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
|
||||
|
||||
read -rp "Migration name: " name
|
||||
|
||||
RUNNING=$(docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 || true)
|
||||
|
||||
if [ -n "$RUNNING" ]; then
|
||||
echo "==> Using running container: $RUNNING"
|
||||
docker compose --profile blue --profile green exec "$RUNNING" aerich migrate --name "$name"
|
||||
else
|
||||
echo "==> No raggr container running — using local postgres"
|
||||
docker compose up -d postgres
|
||||
|
||||
echo "==> Waiting for postgres to be ready..."
|
||||
until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Applying existing migrations..."
|
||||
DATABASE_URL="$LOCAL_DB_URL" .venv/bin/aerich upgrade
|
||||
|
||||
echo "==> Generating migration: $name"
|
||||
DATABASE_URL="$LOCAL_DB_URL" .venv/bin/aerich migrate --name "$name"
|
||||
fi
|
||||
@@ -1,24 +0,0 @@
|
||||
from bs4 import BeautifulSoup
|
||||
import chromadb
|
||||
import httpx
|
||||
|
||||
client = chromadb.PersistentClient(path="/Users/ryanchen/Programs/raggr/chromadb")
|
||||
|
||||
# Scrape
|
||||
BASE_URL = "https://www.vet.cornell.edu"
|
||||
LIST_URL = "/departments-centers-and-institutes/cornell-feline-health-center/health-information/feline-health-topics"
|
||||
|
||||
QUERY_URL = BASE_URL + LIST_URL
|
||||
r = httpx.get(QUERY_URL)
|
||||
soup = BeautifulSoup(r.text)
|
||||
|
||||
container = soup.find("div", class_="field-body")
|
||||
a_s = container.find_all("a", href=True)
|
||||
|
||||
new_texts = []
|
||||
|
||||
for link in a_s:
|
||||
endpoint = link["href"]
|
||||
query_url = BASE_URL + endpoint
|
||||
r2 = httpx.get(query_url)
|
||||
article_soup = BeautifulSoup(r2.text)
|
||||
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
.git/
|
||||
docs/
|
||||
@@ -0,0 +1,8 @@
|
||||
PETKIT_USERNAME=your_email@example.com
|
||||
PETKIT_PASSWORD=your_password
|
||||
PETKIT_REGION=US
|
||||
PETKIT_TIMEZONE=America/New_York
|
||||
POLL_INTERVAL_SECONDS=120
|
||||
DB_PATH=simbakit.db
|
||||
WEB_HOST=127.0.0.1
|
||||
WEB_PORT=8585
|
||||
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.env
|
||||
*.db
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY src/ src/
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
VOLUME /app/data
|
||||
ENV DB_PATH=/app/data/simbakit.db
|
||||
ENV WEB_HOST=0.0.0.0
|
||||
|
||||
EXPOSE 8585
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "simbakit"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Petlibro Integration (Future)
|
||||
|
||||
No standalone PyPI package exists. Best source is the Home Assistant custom integration:
|
||||
https://github.com/jjjonesjr33/petlibro
|
||||
|
||||
## API Overview
|
||||
|
||||
- **Base URL**: `https://api.us.petlibro.com`
|
||||
- **Auth**: POST `/member/auth/login` with email + MD5(password), returns token
|
||||
- **Token**: passed in `token` header on all subsequent requests
|
||||
- **Deps**: just `aiohttp`
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/device/device/list` | List all devices |
|
||||
| `/device/device/realInfo` | Real-time device status |
|
||||
| `/data/data/realInfo` | Real-time sensor data |
|
||||
| `/data/event/deviceEventsV2` | Event/activity logs |
|
||||
| `/device/feedingPlan/list` | Feeding schedules |
|
||||
| `/device/device/manualFeed` | Trigger manual feed |
|
||||
|
||||
## Available Data (Feeders)
|
||||
|
||||
- Serial number, model, MAC, firmware
|
||||
- Wi-Fi SSID + signal strength
|
||||
- Battery level/percentage
|
||||
- Today's total feeding amount (weight & volume)
|
||||
- Last/next feed time
|
||||
- Feeding plan status
|
||||
- Desiccant/filter cycle info
|
||||
|
||||
## Supported Devices
|
||||
|
||||
- Granary Smart Feeder (PLAF103)
|
||||
- Space Smart Feeder (PLAF107)
|
||||
- Air Smart Feeder (PLAF108)
|
||||
- Polar Wet Food Feeder (PLAF109)
|
||||
- Granary Smart Camera Feeder (PLAF203)
|
||||
- One RFID Smart Feeder (PLAF301)
|
||||
- Dockstream Smart Fountain (PLWF105)
|
||||
- Dockstream RFID Smart Fountain (PLWF305)
|
||||
|
||||
## Extraction Plan
|
||||
|
||||
Extract from HA integration (`custom_components/petlibro/`):
|
||||
- `api.py` - main client (remove HA imports, use standalone aiohttp session)
|
||||
- `const.py` - constants/enums
|
||||
- `exceptions.py` - error handling
|
||||
- `devices/` - device model classes (optional)
|
||||
|
||||
Request headers:
|
||||
```
|
||||
source: ANDROID
|
||||
language: EN
|
||||
timezone: [configured]
|
||||
version: 1.3.45
|
||||
Content-Type: application/json
|
||||
token: [auth_token]
|
||||
```
|
||||
|
||||
## Config Vars Needed
|
||||
|
||||
```
|
||||
PETLIBRO_EMAIL=
|
||||
PETLIBRO_PASSWORD=
|
||||
PETLIBRO_REGION=US
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# SimbaKit
|
||||
|
||||
A self-hosted PetKit data polling service with a web dashboard. Polls the PetKit cloud API every 2 minutes and stores everything in SQLite — device snapshots, alerts, pet weight, and litter box activity.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python 3.14** + **uv** for package management
|
||||
- **pypetkitapi** for PetKit cloud API
|
||||
- **aiohttp** for HTTP + web server
|
||||
- **aiosqlite** for async SQLite
|
||||
- **Chart.js** for frontend charts
|
||||
- **Docker** for deployment
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard
|
||||
- Device cards with live status (firmware, serial, last seen)
|
||||
- Alert banner — surfaces warnings and critical issues from all devices
|
||||
- Per-device detail panel with time-series charts and snapshot history
|
||||
- Full raw JSON stored per poll (nothing is ever lost)
|
||||
|
||||
### Alerts
|
||||
Automatically extracted per poll cycle based on device state:
|
||||
|
||||
| Device | Alerts |
|
||||
|--------|--------|
|
||||
| Litter box | Waste bin full, litter low, deodorant empty/low/expiring, pet detection error, device errors |
|
||||
| Water fountain | Water low, filter warning/low %, malfunction |
|
||||
| Feeder | Food low, blockage, desiccant expiring, battery low |
|
||||
| Purifier | Liquid low, consumable expiring |
|
||||
|
||||
### Weight Tracker
|
||||
- Pulls pet weight from litter box camera events (`device_pet_graph_out`)
|
||||
- Stats: latest, average, min/max range, trend direction
|
||||
- Chart with multi-pet support
|
||||
- Filter by pet and time range (7d / 30d / 90d / 1y)
|
||||
|
||||
### Litter Activity
|
||||
- Tracks every litter box visit — poop vs pee (`is_shit`)
|
||||
- Stats: total visits, poop/pee count, avg time in box, avg poop weight
|
||||
- Event timeline with pet name, duration, weight
|
||||
|
||||
## Running
|
||||
|
||||
### Local
|
||||
```bash
|
||||
cp .env.example .env # fill in PetKit credentials
|
||||
uv sync
|
||||
uv run python -m simbakit
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Dashboard at `http://localhost:8585`.
|
||||
|
||||
## Config (`.env`)
|
||||
|
||||
```
|
||||
PETKIT_USERNAME=your_email@example.com
|
||||
PETKIT_PASSWORD=your_password
|
||||
PETKIT_REGION=US
|
||||
PETKIT_TIMEZONE=America/New_York
|
||||
POLL_INTERVAL_SECONDS=120
|
||||
DB_PATH=simbakit.db
|
||||
WEB_HOST=127.0.0.1
|
||||
WEB_PORT=8585
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
simbakit/
|
||||
├── Dockerfile
|
||||
├── compose.yaml
|
||||
├── pyproject.toml
|
||||
├── .env.example
|
||||
├── docs/
|
||||
│ └── petlibro-integration.md # future: Petlibro feeder support
|
||||
└── src/simbakit/
|
||||
├── __main__.py # entry point, signal handling
|
||||
├── config.py # frozen dataclass from env vars
|
||||
├── database.py # SQLite schema + read/write
|
||||
├── poller.py # PetKit polling + field extraction
|
||||
├── web.py # REST API (aiohttp)
|
||||
└── static/index.html # dashboard SPA
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `devices` | Device registry, upserted each poll |
|
||||
| `device_snapshots` | Append-only full state snapshots with raw JSON |
|
||||
| `alerts` | Extracted alerts per poll cycle |
|
||||
| `pet_weights` | Weight measurements from litter box events |
|
||||
| `litter_events` | Poop/pee visit log with duration and weights |
|
||||
|
||||
## Future
|
||||
|
||||
- **Petlibro feeder support** — API research done, notes in `docs/petlibro-integration.md`
|
||||
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "simbakit"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pypetkitapi>=1.28.0",
|
||||
"aiohttp",
|
||||
"aiosqlite",
|
||||
"python-dotenv",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
simbakit = "simbakit.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/simbakit"]
|
||||
@@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from simbakit.config import Config
|
||||
from simbakit.database import Database
|
||||
from simbakit.poller import run_poller
|
||||
from simbakit.web import create_app
|
||||
|
||||
|
||||
def _handle_signal(
|
||||
sig: signal.Signals, shutdown_event: asyncio.Event, log: logging.Logger
|
||||
) -> None:
|
||||
log.info("Received %s, shutting down...", sig.name)
|
||||
shutdown_event.set()
|
||||
|
||||
|
||||
async def _async_main(config: Config) -> None:
|
||||
logger = logging.getLogger("simbakit")
|
||||
|
||||
db = Database(config.db_path)
|
||||
await db.initialize()
|
||||
|
||||
shutdown_event = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _handle_signal, sig, shutdown_event, logger)
|
||||
|
||||
# Start web server
|
||||
app = create_app(db)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, config.web_host, config.web_port)
|
||||
await site.start()
|
||||
logger.info("Web UI available at http://%s:%d", config.web_host, config.web_port)
|
||||
|
||||
logger.info(
|
||||
"SimbaKit starting -- polling %s every %ds",
|
||||
config.petkit_region,
|
||||
config.poll_interval_seconds,
|
||||
)
|
||||
try:
|
||||
await run_poller(config, db, shutdown_event)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
await db.close()
|
||||
logger.info("SimbaKit shut down cleanly")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("simbakit")
|
||||
|
||||
try:
|
||||
config = Config()
|
||||
except KeyError as e:
|
||||
logger.error("Missing required environment variable: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_async_main(config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
petkit_username: str = field(default_factory=lambda: os.environ["PETKIT_USERNAME"])
|
||||
petkit_password: str = field(default_factory=lambda: os.environ["PETKIT_PASSWORD"])
|
||||
petkit_region: str = field(default_factory=lambda: os.getenv("PETKIT_REGION", "US"))
|
||||
petkit_timezone: str = field(
|
||||
default_factory=lambda: os.getenv("PETKIT_TIMEZONE", "America/New_York")
|
||||
)
|
||||
poll_interval_seconds: int = field(
|
||||
default_factory=lambda: int(os.getenv("POLL_INTERVAL_SECONDS", "120"))
|
||||
)
|
||||
db_path: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("DB_PATH", "simbakit.db"))
|
||||
)
|
||||
web_host: str = field(default_factory=lambda: os.getenv("WEB_HOST", "127.0.0.1"))
|
||||
web_port: int = field(default_factory=lambda: int(os.getenv("WEB_PORT", "8585")))
|
||||
@@ -0,0 +1,494 @@
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
sn TEXT,
|
||||
firmware TEXT,
|
||||
first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
raw_json TEXT NOT NULL,
|
||||
name TEXT,
|
||||
firmware TEXT,
|
||||
error_code INTEGER,
|
||||
error_msg TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_device_time
|
||||
ON device_snapshots(device_id, polled_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_polled_at
|
||||
ON device_snapshots(polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
alert_key TEXT NOT NULL,
|
||||
alert_label TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
value TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_device_time
|
||||
ON alerts(device_id, polled_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_polled_at
|
||||
ON alerts(polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pet_weights (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
pet_name TEXT,
|
||||
pet_id TEXT,
|
||||
weight_g INTEGER NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
duration_s INTEGER,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id),
|
||||
UNIQUE(device_id, pet_name, recorded_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pet_weights_name_time
|
||||
ON pet_weights(pet_name, recorded_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS litter_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
event_id TEXT UNIQUE,
|
||||
pet_name TEXT,
|
||||
pet_id TEXT,
|
||||
recorded_at TEXT NOT NULL,
|
||||
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
is_shit INTEGER,
|
||||
pet_weight_g INTEGER,
|
||||
shit_weight_g INTEGER,
|
||||
duration_s INTEGER,
|
||||
toilet_time_s INTEGER,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_litter_events_time
|
||||
ON litter_events(recorded_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_litter_events_pet
|
||||
ON litter_events(pet_name, recorded_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drinking_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
event_id TEXT UNIQUE,
|
||||
pet_name TEXT,
|
||||
pet_id TEXT,
|
||||
recorded_at TEXT NOT NULL,
|
||||
polled_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
record_type TEXT,
|
||||
duration_s INTEGER,
|
||||
stay_time_s INTEGER,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drinking_events_time
|
||||
ON drinking_events(recorded_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drinking_events_pet
|
||||
ON drinking_events(pet_name, recorded_at);
|
||||
"""
|
||||
|
||||
_UPSERT_DEVICE = """
|
||||
INSERT INTO devices (device_id, device_type, name, sn, firmware, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
device_type = excluded.device_type,
|
||||
name = excluded.name,
|
||||
sn = excluded.sn,
|
||||
firmware = excluded.firmware,
|
||||
last_seen_at = datetime('now');
|
||||
"""
|
||||
|
||||
_INSERT_SNAPSHOT = """
|
||||
INSERT INTO device_snapshots (device_id, device_type, raw_json, name, firmware, error_code, error_msg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = db_path
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self._conn = await aiosqlite.connect(self._db_path)
|
||||
await self._conn.executescript(_SCHEMA)
|
||||
await self._conn.commit()
|
||||
|
||||
async def upsert_device(
|
||||
self,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
name: str | None,
|
||||
sn: str | None,
|
||||
firmware: str | None,
|
||||
) -> None:
|
||||
assert self._conn is not None
|
||||
await self._conn.execute(
|
||||
_UPSERT_DEVICE, (device_id, device_type, name, sn, firmware)
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def insert_snapshot(
|
||||
self,
|
||||
device_id: str,
|
||||
device_type: str,
|
||||
raw_json: str,
|
||||
name: str | None = None,
|
||||
firmware: str | None = None,
|
||||
error_code: int | None = None,
|
||||
error_msg: str | None = None,
|
||||
) -> None:
|
||||
assert self._conn is not None
|
||||
await self._conn.execute(
|
||||
_INSERT_SNAPSHOT,
|
||||
(device_id, device_type, raw_json, name, firmware, error_code, error_msg),
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def insert_alerts(
|
||||
self,
|
||||
device_id: str,
|
||||
alerts: list[dict],
|
||||
) -> None:
|
||||
assert self._conn is not None
|
||||
if not alerts:
|
||||
return
|
||||
await self._conn.executemany(
|
||||
"INSERT INTO alerts (device_id, alert_key, alert_label, severity, value) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
(device_id, a["key"], a["label"], a["severity"], a.get("value"))
|
||||
for a in alerts
|
||||
],
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def get_active_alerts(self) -> list[dict]:
|
||||
"""Get the most recent alerts per device (from latest poll only)."""
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute("""
|
||||
SELECT a.*, d.name AS device_name, d.device_type
|
||||
FROM alerts a
|
||||
JOIN devices d ON a.device_id = d.device_id
|
||||
WHERE a.polled_at = d.last_seen_at
|
||||
ORDER BY
|
||||
CASE a.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||
a.polled_at DESC
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_alerts_for_device(
|
||||
self, device_id: str, limit: int = 50
|
||||
) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT * FROM alerts WHERE device_id = ? ORDER BY polled_at DESC LIMIT ?",
|
||||
(device_id, limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def insert_litter_events(self, events: list[dict]) -> None:
|
||||
assert self._conn is not None
|
||||
if not events:
|
||||
return
|
||||
await self._conn.executemany(
|
||||
"""INSERT OR IGNORE INTO litter_events
|
||||
(device_id, event_id, pet_name, pet_id, recorded_at,
|
||||
is_shit, pet_weight_g, shit_weight_g, duration_s, toilet_time_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
e["device_id"],
|
||||
e.get("event_id"),
|
||||
e.get("pet_name"),
|
||||
e.get("pet_id"),
|
||||
e["recorded_at"],
|
||||
e.get("is_shit"),
|
||||
e.get("pet_weight_g"),
|
||||
e.get("shit_weight_g"),
|
||||
e.get("duration_s"),
|
||||
e.get("toilet_time_s"),
|
||||
)
|
||||
for e in events
|
||||
],
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def get_litter_events(
|
||||
self, pet_name: str | None = None, days: int = 30
|
||||
) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
if pet_name:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM litter_events
|
||||
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at DESC""",
|
||||
(pet_name, f"-{days} days"),
|
||||
)
|
||||
else:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM litter_events
|
||||
WHERE recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at DESC""",
|
||||
(f"-{days} days",),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_litter_stats(
|
||||
self, pet_name: str | None = None, days: int = 7
|
||||
) -> dict:
|
||||
assert self._conn is not None
|
||||
where = "WHERE recorded_at >= datetime('now', ?)"
|
||||
params: list = [f"-{days} days"]
|
||||
if pet_name:
|
||||
where += " AND pet_name = ?"
|
||||
params.append(pet_name)
|
||||
cursor = await self._conn.execute(
|
||||
f"""SELECT
|
||||
COUNT(*) as total_visits,
|
||||
SUM(CASE WHEN is_shit = 1 THEN 1 ELSE 0 END) as poop_count,
|
||||
SUM(CASE WHEN is_shit = 0 OR is_shit IS NULL THEN 1 ELSE 0 END) as pee_count,
|
||||
AVG(toilet_time_s) as avg_toilet_time,
|
||||
AVG(CASE WHEN shit_weight_g > 0 THEN shit_weight_g END) as avg_shit_weight
|
||||
FROM litter_events {where}""",
|
||||
params,
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return {
|
||||
"total_visits": row[0] or 0,
|
||||
"poop_count": row[1] or 0,
|
||||
"pee_count": row[2] or 0,
|
||||
"avg_toilet_time": round(row[3], 1) if row[3] else 0,
|
||||
"avg_shit_weight": round(row[4], 1) if row[4] else 0,
|
||||
}
|
||||
|
||||
async def insert_pet_weights(self, records: list[dict]) -> None:
|
||||
assert self._conn is not None
|
||||
if not records:
|
||||
return
|
||||
await self._conn.executemany(
|
||||
"""INSERT OR IGNORE INTO pet_weights
|
||||
(device_id, pet_name, pet_id, weight_g, recorded_at, duration_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
r["device_id"],
|
||||
r["pet_name"],
|
||||
r.get("pet_id"),
|
||||
r["weight_g"],
|
||||
r["recorded_at"],
|
||||
r.get("duration_s"),
|
||||
)
|
||||
for r in records
|
||||
],
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def get_pet_weights(
|
||||
self, pet_name: str | None = None, days: int = 30
|
||||
) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
if pet_name:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM pet_weights
|
||||
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at ASC""",
|
||||
(pet_name, f"-{days} days"),
|
||||
)
|
||||
else:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM pet_weights
|
||||
WHERE recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at ASC""",
|
||||
(f"-{days} days",),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def insert_drinking_events(self, events: list[dict]) -> None:
|
||||
assert self._conn is not None
|
||||
if not events:
|
||||
return
|
||||
await self._conn.executemany(
|
||||
"""INSERT OR IGNORE INTO drinking_events
|
||||
(device_id, event_id, pet_name, pet_id, recorded_at,
|
||||
record_type, duration_s, stay_time_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
(
|
||||
e["device_id"],
|
||||
e.get("event_id"),
|
||||
e.get("pet_name"),
|
||||
e.get("pet_id"),
|
||||
e["recorded_at"],
|
||||
e.get("record_type"),
|
||||
e.get("duration_s"),
|
||||
e.get("stay_time_s"),
|
||||
)
|
||||
for e in events
|
||||
],
|
||||
)
|
||||
await self._conn.commit()
|
||||
|
||||
async def get_drinking_events(
|
||||
self, pet_name: str | None = None, days: int = 7
|
||||
) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
if pet_name:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM drinking_events
|
||||
WHERE pet_name = ? AND recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at DESC""",
|
||||
(pet_name, f"-{days} days"),
|
||||
)
|
||||
else:
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT * FROM drinking_events
|
||||
WHERE recorded_at >= datetime('now', ?)
|
||||
ORDER BY recorded_at DESC""",
|
||||
(f"-{days} days",),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_drinking_stats(
|
||||
self, pet_name: str | None = None, days: int = 7
|
||||
) -> dict:
|
||||
assert self._conn is not None
|
||||
where = "WHERE recorded_at >= datetime('now', ?)"
|
||||
params: list = [f"-{days} days"]
|
||||
if pet_name:
|
||||
where += " AND pet_name = ?"
|
||||
params.append(pet_name)
|
||||
cursor = await self._conn.execute(
|
||||
f"""SELECT
|
||||
SUM(CASE WHEN record_type = 'drink_over' THEN 1 ELSE 0 END) as drink_count,
|
||||
SUM(CASE WHEN record_type = 'pet_detect' THEN 1 ELSE 0 END) as detect_count,
|
||||
AVG(CASE WHEN record_type = 'drink_over' THEN duration_s END) as avg_duration,
|
||||
AVG(CASE WHEN record_type = 'drink_over' THEN stay_time_s END) as avg_stay_time
|
||||
FROM drinking_events {where}""",
|
||||
params,
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
cursor = await self._conn.execute(
|
||||
f"""SELECT pet_name, COUNT(*) as drinks, AVG(duration_s) as avg_duration
|
||||
FROM drinking_events {where} AND record_type = 'drink_over'
|
||||
AND pet_name IS NOT NULL
|
||||
GROUP BY pet_name ORDER BY drinks DESC""",
|
||||
params,
|
||||
)
|
||||
per_pet = [
|
||||
{
|
||||
"pet_name": r[0],
|
||||
"drinks": r[1],
|
||||
"avg_duration": round(r[2], 1) if r[2] else 0,
|
||||
}
|
||||
for r in await cursor.fetchall()
|
||||
]
|
||||
return {
|
||||
"total_drinks": row[0] or 0,
|
||||
"detections": row[1] or 0,
|
||||
"avg_duration": round(row[2], 1) if row[2] else 0,
|
||||
"avg_stay_time": round(row[3], 1) if row[3] else 0,
|
||||
"per_pet": per_pet,
|
||||
}
|
||||
|
||||
async def get_pet_names(self) -> list[str]:
|
||||
assert self._conn is not None
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT DISTINCT pet_name FROM pet_weights WHERE pet_name IS NOT NULL
|
||||
UNION
|
||||
SELECT DISTINCT pet_name FROM drinking_events WHERE pet_name IS NOT NULL
|
||||
ORDER BY pet_name"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def get_all_devices(self) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT * FROM devices ORDER BY last_seen_at DESC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_latest_snapshot_per_device(self) -> dict[str, dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute("""
|
||||
SELECT ds.* FROM device_snapshots ds
|
||||
INNER JOIN (
|
||||
SELECT device_id, MAX(polled_at) AS max_polled
|
||||
FROM device_snapshots GROUP BY device_id
|
||||
) latest ON ds.device_id = latest.device_id
|
||||
AND ds.polled_at = latest.max_polled
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return {r["device_id"]: dict(r) for r in rows}
|
||||
|
||||
async def get_snapshots(
|
||||
self, device_id: str, limit: int = 100, offset: int = 0
|
||||
) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT * FROM device_snapshots WHERE device_id = ? ORDER BY polled_at DESC LIMIT ? OFFSET ?",
|
||||
(device_id, limit, offset),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_snapshot_count(self, device_id: str) -> int:
|
||||
assert self._conn is not None
|
||||
cursor = await self._conn.execute(
|
||||
"SELECT COUNT(*) FROM device_snapshots WHERE device_id = ?",
|
||||
(device_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
async def get_chart_data(self, device_id: str, hours: int = 24) -> list[dict]:
|
||||
assert self._conn is not None
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
cursor = await self._conn.execute(
|
||||
"""SELECT id, polled_at, raw_json, error_code FROM device_snapshots
|
||||
WHERE device_id = ? AND polled_at >= datetime('now', ?)
|
||||
ORDER BY polled_at ASC""",
|
||||
(device_id, f"-{hours} hours"),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
@@ -0,0 +1,566 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from pypetkitapi.client import PetKitClient
|
||||
|
||||
from simbakit.config import Config
|
||||
from simbakit.database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_entity(entity: object) -> str:
|
||||
if hasattr(entity, "model_dump"):
|
||||
return json.dumps(entity.model_dump(), default=str)
|
||||
return json.dumps(vars(entity), default=str)
|
||||
|
||||
|
||||
def _extract_fields(entity: object) -> dict:
|
||||
state = getattr(entity, "state", None)
|
||||
return {
|
||||
"name": getattr(entity, "name", None),
|
||||
"firmware": str(fw) if (fw := getattr(entity, "firmware", None)) else None,
|
||||
"error_code": getattr(state, "error_code", None)
|
||||
or getattr(entity, "error_code", None),
|
||||
"error_msg": getattr(state, "error_msg", None)
|
||||
or getattr(entity, "error_msg", None),
|
||||
}
|
||||
|
||||
|
||||
def _extract_alerts(entity: object) -> list[dict]:
|
||||
"""Extract active alerts from a device entity based on its type."""
|
||||
alerts = []
|
||||
state = getattr(entity, "state", None)
|
||||
if state is None:
|
||||
return alerts
|
||||
|
||||
# --- Litter box alerts ---
|
||||
if hasattr(state, "box_full") and state.box_full:
|
||||
alerts.append(
|
||||
{"key": "box_full", "label": "Waste bin is full", "severity": "critical"}
|
||||
)
|
||||
|
||||
if hasattr(state, "sand_lack") and state.sand_lack:
|
||||
alerts.append(
|
||||
{"key": "sand_lack", "label": "Litter is low", "severity": "warning"}
|
||||
)
|
||||
|
||||
# sand_percent: 0 is a default/unused value on some models; cross-check with sand_weight
|
||||
sand_pct = getattr(state, "sand_percent", None)
|
||||
sand_weight = getattr(state, "sand_weight", None)
|
||||
if sand_pct is not None and isinstance(sand_pct, (int, float)) and sand_pct > 0:
|
||||
if sand_pct <= 15:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "sand_percent",
|
||||
"label": f"Litter level critically low ({sand_pct}%)",
|
||||
"severity": "critical",
|
||||
"value": str(sand_pct),
|
||||
}
|
||||
)
|
||||
elif sand_pct <= 30:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "sand_percent",
|
||||
"label": f"Litter level low ({sand_pct}%)",
|
||||
"severity": "warning",
|
||||
"value": str(sand_pct),
|
||||
}
|
||||
)
|
||||
elif (
|
||||
sand_pct == 0
|
||||
and sand_weight
|
||||
and isinstance(sand_weight, (int, float))
|
||||
and sand_weight < 1000
|
||||
):
|
||||
alerts.append(
|
||||
{
|
||||
"key": "sand_percent",
|
||||
"label": "Litter level very low",
|
||||
"severity": "critical",
|
||||
"value": "0",
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(state, "liquid_empty") and state.liquid_empty:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "liquid_empty",
|
||||
"label": "Deodorant is empty",
|
||||
"severity": "critical",
|
||||
}
|
||||
)
|
||||
elif hasattr(state, "liquid_lack") and state.liquid_lack:
|
||||
alerts.append(
|
||||
{"key": "liquid_lack", "label": "Deodorant is low", "severity": "warning"}
|
||||
)
|
||||
|
||||
# deodorant_left_days: 0 can mean "not installed" on some models; only alert if > 0 and low
|
||||
deo_days = getattr(state, "deodorant_left_days", None)
|
||||
if (
|
||||
deo_days is not None
|
||||
and isinstance(deo_days, (int, float))
|
||||
and 0 < deo_days <= 3
|
||||
):
|
||||
alerts.append(
|
||||
{
|
||||
"key": "deodorant_left_days",
|
||||
"label": f"Deodorant expires in {deo_days} day(s)",
|
||||
"severity": "warning",
|
||||
"value": str(deo_days),
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(state, "pet_error") and state.pet_error:
|
||||
alerts.append(
|
||||
{"key": "pet_error", "label": "Pet detection error", "severity": "warning"}
|
||||
)
|
||||
|
||||
# Litter error state
|
||||
err_code = getattr(state, "error_code", None)
|
||||
err_msg = getattr(state, "error_msg", None)
|
||||
if err_code and str(err_code) != "0":
|
||||
alerts.append(
|
||||
{
|
||||
"key": "error",
|
||||
"label": f"Device error: {err_msg or err_code}",
|
||||
"severity": "critical",
|
||||
"value": str(err_code),
|
||||
}
|
||||
)
|
||||
|
||||
# --- Water fountain alerts ---
|
||||
if hasattr(entity, "lack_warning") and entity.lack_warning:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "lack_warning",
|
||||
"label": "Water level is low",
|
||||
"severity": "critical",
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(entity, "filter_warning") and entity.filter_warning:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "filter_warning",
|
||||
"label": "Filter needs replacement",
|
||||
"severity": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
filter_pct = getattr(entity, "filter_percent", None)
|
||||
if filter_pct is not None and isinstance(filter_pct, (int, float)):
|
||||
if filter_pct <= 10:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "filter_percent",
|
||||
"label": f"Filter life critically low ({filter_pct}%)",
|
||||
"severity": "critical",
|
||||
"value": str(filter_pct),
|
||||
}
|
||||
)
|
||||
elif filter_pct <= 25:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "filter_percent",
|
||||
"label": f"Filter life low ({filter_pct}%)",
|
||||
"severity": "warning",
|
||||
"value": str(filter_pct),
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(entity, "breakdown_warning") and entity.breakdown_warning:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "breakdown_warning",
|
||||
"label": "Fountain malfunction detected",
|
||||
"severity": "critical",
|
||||
}
|
||||
)
|
||||
|
||||
# --- Eversweet Ultra (W7H) fountain alerts ---
|
||||
# cwt_state is distinctive to the W7H FountainState model
|
||||
if hasattr(state, "cwt_state"):
|
||||
if getattr(state, "stg_full_state", None) and getattr(
|
||||
state, "stg_install", None
|
||||
):
|
||||
alerts.append(
|
||||
{
|
||||
"key": "waste_tank_full",
|
||||
"label": "Waste water tank is full",
|
||||
"severity": "critical",
|
||||
}
|
||||
)
|
||||
|
||||
filter_days = getattr(state, "filter_left_days", None)
|
||||
if filter_days is not None and isinstance(filter_days, (int, float)):
|
||||
if 0 < filter_days <= 3:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "filter_left_days",
|
||||
"label": f"Filter expires in {filter_days} day(s)",
|
||||
"severity": "critical",
|
||||
"value": str(filter_days),
|
||||
}
|
||||
)
|
||||
elif 3 < filter_days <= 7:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "filter_left_days",
|
||||
"label": f"Filter expires in {filter_days} day(s)",
|
||||
"severity": "warning",
|
||||
"value": str(filter_days),
|
||||
}
|
||||
)
|
||||
|
||||
# Semantics of these state ints are unverified; log them so alert
|
||||
# thresholds can be tuned against real data later.
|
||||
logger.info(
|
||||
"W7H fountain state: cwt=%s wt=%s stg_full=%s pump=%s water_pump=%s filter_days=%s",
|
||||
getattr(state, "cwt_state", None),
|
||||
getattr(state, "wt_state", None),
|
||||
getattr(state, "stg_full_state", None),
|
||||
getattr(state, "pump_state", None),
|
||||
getattr(state, "water_pump_state", None),
|
||||
getattr(state, "filter_left_days", None),
|
||||
)
|
||||
|
||||
# --- Feeder alerts ---
|
||||
food_level = getattr(state, "food", None)
|
||||
if food_level is not None and isinstance(food_level, (int, float)):
|
||||
if food_level <= 10:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "food",
|
||||
"label": f"Food level critically low ({food_level}%)",
|
||||
"severity": "critical",
|
||||
"value": str(food_level),
|
||||
}
|
||||
)
|
||||
elif food_level <= 25:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "food",
|
||||
"label": f"Food level low ({food_level}%)",
|
||||
"severity": "warning",
|
||||
"value": str(food_level),
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(state, "block") and state.block:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "block",
|
||||
"label": "Feeder blockage detected",
|
||||
"severity": "critical",
|
||||
}
|
||||
)
|
||||
|
||||
desiccant_days = getattr(state, "desiccant_left_days", None)
|
||||
if (
|
||||
desiccant_days is not None
|
||||
and isinstance(desiccant_days, (int, float))
|
||||
and desiccant_days <= 3
|
||||
):
|
||||
alerts.append(
|
||||
{
|
||||
"key": "desiccant_left_days",
|
||||
"label": f"Desiccant expires in {desiccant_days} day(s)",
|
||||
"severity": "warning",
|
||||
"value": str(desiccant_days),
|
||||
}
|
||||
)
|
||||
|
||||
battery_pwr = getattr(state, "battery_power", None) or getattr(
|
||||
state, "battery", None
|
||||
)
|
||||
if (
|
||||
battery_pwr is not None
|
||||
and isinstance(battery_pwr, (int, float))
|
||||
and 0 <= battery_pwr <= 15
|
||||
):
|
||||
alerts.append(
|
||||
{
|
||||
"key": "battery",
|
||||
"label": f"Battery low ({battery_pwr}%)",
|
||||
"severity": "warning",
|
||||
"value": str(battery_pwr),
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(state, "low_power") and state.low_power:
|
||||
alerts.append({"key": "low_power", "label": "Low power", "severity": "warning"})
|
||||
|
||||
# --- Purifier alerts ---
|
||||
if hasattr(entity, "liquid_lack") and entity.liquid_lack:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "liquid_lack",
|
||||
"label": "Purifier liquid is low",
|
||||
"severity": "warning",
|
||||
}
|
||||
)
|
||||
|
||||
left_day = getattr(state, "left_day", None)
|
||||
if left_day is not None and isinstance(left_day, (int, float)) and left_day <= 3:
|
||||
alerts.append(
|
||||
{
|
||||
"key": "left_day",
|
||||
"label": f"Purifier consumable expires in {left_day} day(s)",
|
||||
"severity": "warning",
|
||||
"value": str(left_day),
|
||||
}
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def _extract_pet_weights(entity: object, device_id: str) -> list[dict]:
|
||||
"""Extract pet weight records from litter box PetOutGraph data."""
|
||||
records = []
|
||||
|
||||
# From device_pet_graph_out (T5/T6/T7 with camera)
|
||||
graph_out = getattr(entity, "device_pet_graph_out", None)
|
||||
if graph_out:
|
||||
for event in graph_out:
|
||||
content = getattr(event, "content", None)
|
||||
weight = getattr(content, "pet_weight", None) if content else None
|
||||
if weight and isinstance(weight, (int, float)) and weight > 0:
|
||||
ts = getattr(event, "time", None)
|
||||
recorded_at = (
|
||||
datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if ts
|
||||
else None
|
||||
)
|
||||
if recorded_at:
|
||||
duration = getattr(event, "duration", None)
|
||||
records.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"pet_name": getattr(event, "pet_name", None),
|
||||
"pet_id": str(pid)
|
||||
if (pid := getattr(event, "pet_id", None))
|
||||
else None,
|
||||
"weight_g": int(weight),
|
||||
"recorded_at": recorded_at,
|
||||
"duration_s": duration,
|
||||
}
|
||||
)
|
||||
|
||||
# From device_records (T3/T4 and others) - may be a list or a single object
|
||||
device_records = getattr(entity, "device_records", None)
|
||||
if device_records and isinstance(device_records, list):
|
||||
for rec in device_records:
|
||||
content = getattr(rec, "content", None)
|
||||
weight = getattr(content, "pet_weight", None) if content else None
|
||||
if weight and isinstance(weight, (int, float)) and weight > 0:
|
||||
ts = getattr(content, "time_in", None) or getattr(rec, "time", None)
|
||||
recorded_at = (
|
||||
datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if ts
|
||||
else None
|
||||
)
|
||||
if recorded_at:
|
||||
records.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"pet_name": getattr(rec, "pet_name", None),
|
||||
"pet_id": str(pid)
|
||||
if (pid := getattr(rec, "pet_id", None))
|
||||
else None,
|
||||
"weight_g": int(weight),
|
||||
"recorded_at": recorded_at,
|
||||
"duration_s": getattr(content, "interval", None),
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _extract_litter_events(entity: object, device_id: str) -> list[dict]:
|
||||
"""Extract litter box visit events (poop/pee) from PetOutGraph data."""
|
||||
events = []
|
||||
|
||||
graph_out = getattr(entity, "device_pet_graph_out", None)
|
||||
if not graph_out:
|
||||
return events
|
||||
|
||||
for event in graph_out:
|
||||
ts = getattr(event, "time", None)
|
||||
if not ts:
|
||||
continue
|
||||
recorded_at = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
content = getattr(event, "content", None)
|
||||
|
||||
events.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"event_id": getattr(event, "event_id", None),
|
||||
"pet_name": getattr(event, "pet_name", None),
|
||||
"pet_id": str(pid) if (pid := getattr(event, "pet_id", None)) else None,
|
||||
"recorded_at": recorded_at,
|
||||
"is_shit": getattr(content, "is_shit", None) if content else None,
|
||||
"pet_weight_g": getattr(content, "pet_weight", None)
|
||||
if content
|
||||
else None,
|
||||
"shit_weight_g": getattr(content, "shit_weight", None)
|
||||
if content
|
||||
else None,
|
||||
"duration_s": getattr(event, "duration", None),
|
||||
"toilet_time_s": getattr(event, "toilet_time", None),
|
||||
}
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
DRINKING_RECORD_TYPES = {"drink_over", "pet_detect"}
|
||||
|
||||
|
||||
def _extract_drinking_events(entity: object, device_id: str) -> list[dict]:
|
||||
"""Extract fountain drinking/visit events from W7H device records."""
|
||||
events = []
|
||||
|
||||
# Litter boxes also populate device_records; only fountains have drink data
|
||||
if type(entity).__name__ != "WaterFountain":
|
||||
return events
|
||||
|
||||
device_records = getattr(entity, "device_records", None)
|
||||
if not device_records or not isinstance(device_records, list):
|
||||
return events
|
||||
|
||||
for rec in device_records:
|
||||
raw_type = getattr(rec, "enum_event_type", None)
|
||||
record_type = str(getattr(raw_type, "value", raw_type)) if raw_type else None
|
||||
if record_type not in DRINKING_RECORD_TYPES:
|
||||
continue
|
||||
|
||||
ts = getattr(rec, "timestamp", None)
|
||||
if not ts:
|
||||
continue
|
||||
recorded_at = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
events.append(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"event_id": getattr(rec, "event_id", None),
|
||||
"pet_name": getattr(rec, "pet_name", None),
|
||||
"pet_id": str(pid) if (pid := getattr(rec, "pet_id", None)) else None,
|
||||
"recorded_at": recorded_at,
|
||||
"record_type": record_type,
|
||||
"duration_s": getattr(rec, "duration", None),
|
||||
"stay_time_s": getattr(rec, "stay_time", None),
|
||||
}
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def poll_once(config: Config, db: Database) -> int:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = PetKitClient(
|
||||
username=config.petkit_username,
|
||||
password=config.petkit_password,
|
||||
region=config.petkit_region,
|
||||
timezone=config.petkit_timezone,
|
||||
session=session,
|
||||
)
|
||||
await client.get_devices_data()
|
||||
|
||||
count = 0
|
||||
for device_id, entity in client.petkit_entities.items():
|
||||
device_type = type(entity).__name__
|
||||
raw_json = _serialize_entity(entity)
|
||||
fields = _extract_fields(entity)
|
||||
|
||||
await db.upsert_device(
|
||||
device_id=str(device_id),
|
||||
device_type=device_type,
|
||||
name=fields["name"],
|
||||
sn=getattr(entity, "sn", None),
|
||||
firmware=fields["firmware"],
|
||||
)
|
||||
await db.insert_snapshot(
|
||||
device_id=str(device_id),
|
||||
device_type=device_type,
|
||||
raw_json=raw_json,
|
||||
**fields,
|
||||
)
|
||||
|
||||
alerts = _extract_alerts(entity)
|
||||
if alerts:
|
||||
await db.insert_alerts(str(device_id), alerts)
|
||||
for a in alerts:
|
||||
logger.warning(
|
||||
"ALERT [%s] %s: %s",
|
||||
fields["name"],
|
||||
a["severity"].upper(),
|
||||
a["label"],
|
||||
)
|
||||
|
||||
weight_records = _extract_pet_weights(entity, str(device_id))
|
||||
if weight_records:
|
||||
await db.insert_pet_weights(weight_records)
|
||||
logger.info(
|
||||
"Stored %d pet weight record(s) from %s",
|
||||
len(weight_records),
|
||||
fields["name"],
|
||||
)
|
||||
|
||||
litter_events = _extract_litter_events(entity, str(device_id))
|
||||
if litter_events:
|
||||
await db.insert_litter_events(litter_events)
|
||||
logger.info(
|
||||
"Stored %d litter event(s) from %s", len(litter_events), fields["name"]
|
||||
)
|
||||
|
||||
drinking_events = _extract_drinking_events(entity, str(device_id))
|
||||
if drinking_events:
|
||||
await db.insert_drinking_events(drinking_events)
|
||||
logger.info(
|
||||
"Stored %d drinking event(s) from %s",
|
||||
len(drinking_events),
|
||||
fields["name"],
|
||||
)
|
||||
|
||||
count += 1
|
||||
logger.info(
|
||||
"Stored snapshot for %s '%s' (id=%s, alerts=%d)",
|
||||
device_type,
|
||||
fields["name"],
|
||||
device_id,
|
||||
len(alerts),
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def run_poller(
|
||||
config: Config, db: Database, shutdown_event: asyncio.Event
|
||||
) -> None:
|
||||
logger.info("Poller starting (interval=%ds)", config.poll_interval_seconds)
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
count = await poll_once(config, db)
|
||||
logger.info("Poll complete: %d entities stored", count)
|
||||
except Exception:
|
||||
logger.exception("Poll failed, will retry next cycle")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
shutdown_event.wait(), timeout=config.poll_interval_seconds
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
logger.info("Poller stopped")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from simbakit.database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def create_app(db: Database) -> web.Application:
|
||||
app = web.Application()
|
||||
app["db"] = db
|
||||
app.router.add_get("/", index)
|
||||
app.router.add_get("/api/devices", api_devices)
|
||||
app.router.add_get("/api/devices/{device_id}/snapshots", api_snapshots)
|
||||
app.router.add_get("/api/devices/{device_id}/chart", api_chart)
|
||||
app.router.add_get("/api/alerts", api_alerts)
|
||||
app.router.add_get("/api/devices/{device_id}/alerts", api_device_alerts)
|
||||
app.router.add_get("/api/pets/weights", api_pet_weights)
|
||||
app.router.add_get("/api/pets/litter-events", api_litter_events)
|
||||
app.router.add_get("/api/pets/drinking-events", api_drinking_events)
|
||||
return app
|
||||
|
||||
|
||||
async def index(request: web.Request) -> web.FileResponse:
|
||||
return web.FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
async def api_devices(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
devices = await db.get_all_devices()
|
||||
latest = await db.get_latest_snapshot_per_device()
|
||||
|
||||
result = []
|
||||
for d in devices:
|
||||
snap = latest.get(d["device_id"])
|
||||
entry = {**d}
|
||||
if snap:
|
||||
entry["latest_snapshot"] = {
|
||||
"polled_at": snap["polled_at"],
|
||||
"error_code": snap["error_code"],
|
||||
"error_msg": snap["error_msg"],
|
||||
"raw_json": snap["raw_json"],
|
||||
}
|
||||
result.append(entry)
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def api_snapshots(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
device_id = request.match_info["device_id"]
|
||||
limit = min(int(request.query.get("limit", "50")), 500)
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
|
||||
snapshots = await db.get_snapshots(device_id, limit=limit, offset=offset)
|
||||
total = await db.get_snapshot_count(device_id)
|
||||
|
||||
return web.json_response(
|
||||
{"total": total, "limit": limit, "offset": offset, "snapshots": snapshots}
|
||||
)
|
||||
|
||||
|
||||
async def api_chart(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
device_id = request.match_info["device_id"]
|
||||
hours = min(int(request.query.get("hours", "24")), 168)
|
||||
|
||||
rows = await db.get_chart_data(device_id, hours=hours)
|
||||
|
||||
timestamps = []
|
||||
error_codes = []
|
||||
metrics: dict[str, list] = {}
|
||||
|
||||
for row in rows:
|
||||
timestamps.append(row["polled_at"])
|
||||
error_codes.append(row["error_code"] or 0)
|
||||
|
||||
try:
|
||||
raw = json.loads(row["raw_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Extract numeric fields from state for charting
|
||||
state = raw.get("state", {})
|
||||
if isinstance(state, dict):
|
||||
for key, val in state.items():
|
||||
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
||||
metrics.setdefault(key, []).append(
|
||||
{"t": row["polled_at"], "v": val}
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"timestamps": timestamps,
|
||||
"error_codes": error_codes,
|
||||
"metrics": metrics,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def api_alerts(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
alerts = await db.get_active_alerts()
|
||||
return web.json_response(alerts)
|
||||
|
||||
|
||||
async def api_device_alerts(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
device_id = request.match_info["device_id"]
|
||||
limit = min(int(request.query.get("limit", "50")), 200)
|
||||
alerts = await db.get_alerts_for_device(device_id, limit=limit)
|
||||
return web.json_response(alerts)
|
||||
|
||||
|
||||
async def api_pet_weights(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
pet_name = request.query.get("pet")
|
||||
days = min(int(request.query.get("days", "30")), 365)
|
||||
weights = await db.get_pet_weights(pet_name=pet_name, days=days)
|
||||
pet_names = await db.get_pet_names()
|
||||
return web.json_response({"pet_names": pet_names, "weights": weights})
|
||||
|
||||
|
||||
async def api_litter_events(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
pet_name = request.query.get("pet")
|
||||
days = min(int(request.query.get("days", "7")), 90)
|
||||
events = await db.get_litter_events(pet_name=pet_name, days=days)
|
||||
stats = await db.get_litter_stats(pet_name=pet_name, days=days)
|
||||
pet_names = await db.get_pet_names()
|
||||
return web.json_response({"pet_names": pet_names, "events": events, "stats": stats})
|
||||
|
||||
|
||||
async def api_drinking_events(request: web.Request) -> web.Response:
|
||||
db: Database = request.app["db"]
|
||||
pet_name = request.query.get("pet")
|
||||
days = min(int(request.query.get("days", "7")), 90)
|
||||
events = await db.get_drinking_events(pet_name=pet_name, days=days)
|
||||
stats = await db.get_drinking_stats(pet_name=pet_name, days=days)
|
||||
pet_names = await db.get_pet_names()
|
||||
return web.json_response({"pet_names": pet_names, "events": events, "stats": stats})
|
||||
Generated
+851
@@ -0,0 +1,851 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "24.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
{ name = "aiosignal" },
|
||||
{ name = "attrs" },
|
||||
{ name = "frozenlist" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "frozenlist" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "m3u8"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypetkitapi"
|
||||
version = "1.28.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "m3u8" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/a4/9ae26d4494ce5a51757e66964d4deb3569a2824487710354573f185d5f1d/pypetkitapi-1.28.0.tar.gz", hash = "sha256:41fc506a1b371a732a902c83e024734ce9b34d5b885d7d77bf8104b1f2021b9e", size = 56251, upload-time = "2026-07-08T06:04:25.048Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a5/5479bd8f5ce0c88c031159da72501e32e25617187caead3e9771a471576c/pypetkitapi-1.28.0-py3-none-any.whl", hash = "sha256:a9bd7e923f6a877bcf5283fdabd090bf74a8f05a62bbb70fb3df662554fdbab0", size = 55424, upload-time = "2026-07-08T06:04:26.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simbakit"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "pypetkitapi" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "pypetkitapi", specifier = ">=1.28.0" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
|
||||
]
|
||||
@@ -1,9 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Initializing directories..."
|
||||
mkdir -p /app/data/chromadb
|
||||
|
||||
echo "Rebuilding frontend..."
|
||||
cd /app/raggr-frontend
|
||||
yarn build
|
||||
|
||||
+25
-2
@@ -8,8 +8,31 @@ mkdir -p /app/data/obsidian
|
||||
|
||||
# Start continuous Obsidian sync if enabled
|
||||
if [ "${OBSIDIAN_CONTINUOUS_SYNC}" = "true" ]; then
|
||||
echo "Starting Obsidian continuous sync in background..."
|
||||
ob sync --continuous &
|
||||
if [ -z "${OBSIDIAN_EMAIL}" ] || [ -z "${OBSIDIAN_PASSWORD}" ] || [ -z "${OBSIDIAN_VAULT_ID}" ]; then
|
||||
echo "WARNING: OBSIDIAN_EMAIL, OBSIDIAN_PASSWORD, or OBSIDIAN_VAULT_ID not set. Skipping sync."
|
||||
else
|
||||
echo "Setting up Obsidian sync..."
|
||||
|
||||
VAULT_PATH="${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}"
|
||||
|
||||
# Login and setup sync (foreground, must complete before sync starts)
|
||||
if ob login --email "${OBSIDIAN_EMAIL}" --password "${OBSIDIAN_PASSWORD}" && \
|
||||
ob sync-setup \
|
||||
--vault "${OBSIDIAN_VAULT_ID}" \
|
||||
--path "${VAULT_PATH}" \
|
||||
--password "${OBSIDIAN_E2E_PASSWORD}" \
|
||||
--device-name "${OBSIDIAN_DEVICE_NAME:-simbarag}"; then
|
||||
# Remove stale lock from previous container run
|
||||
rm -rf "${VAULT_PATH}/.obsidian/.sync.lock"
|
||||
# Set sync to pull-only (read-only) mode
|
||||
ob sync-config --mode pull-only --path "${VAULT_PATH}"
|
||||
# Start continuous sync in background
|
||||
echo "Starting Obsidian continuous sync (pull-only)..."
|
||||
ob sync --continuous --path "${VAULT_PATH}" &
|
||||
else
|
||||
echo "WARNING: Obsidian sync setup failed. Continuing without sync."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting application..."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user