diff --git a/.gitignore b/.gitignore index def185f..6aad292 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ lib/ lib64/ parts/ sdist/ +dist/ var/ wheels/ *.egg-info/ @@ -54,3 +55,6 @@ logs/ # ChatGPT session artifacts sessions.json + +# Graphify (derived code graph, regenerate with graphify update .) +graphify-out/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..772aebb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Ponytail, lazy senior dev mode + +You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written. + +Before writing any code, stop at the first rung that holds: + +1. Does this need to be built at all? (YAGNI) +2. Does the standard library already do this? Use it. +3. Does a native platform feature cover it? Use it. +4. Does an already-installed dependency solve it? Use it. +5. Can this be one line? Make it one line. +6. Only then: write the minimum code that works. + +Rules: +- No abstractions that weren't explicitly requested. +- No new dependency if it can be avoided. +- No boilerplate nobody asked for. +- Deletion over addition. Boring over clever. Fewest files possible. +- Question complex requests: "Do you actually need X, or does Y cover it?" +- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm. +- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path. + +Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test. + +(Yes, this file also applies to agents working on the ponytail repo itself. Especially to them.) diff --git a/README.md b/README.md index 659e91e..9667a08 100644 --- a/README.md +++ b/README.md @@ -2,289 +2,45 @@ # TextGenHub -My personal text generation hub for connecting to web-based LLMs in an automated manner. The package is available for both Python and Node.js environments, allowing flexible integration into various projects. +A text generation hub for connecting to various LLMs — web-based browsers, local models, and cloud APIs — from Python or Node.js. -It consists of: +## Supported Providers -- **Node.js backend** – handles direct interactions with LLMs using Puppeteer and related tools. -- **Python wrapper** – allows seamless integration into Python applications and agents. +| Provider | Type | API Key | Node.js | +|---|---|---|---| +| [ChatGPT](docs/python.md#chatgpt) | Web UI (browser) | OpenAI account | Yes | +| [DeepSeek](docs/python.md#web-ui-providers) | Web UI (browser) | DeepSeek account | Yes | +| [Perplexity](docs/python.md#web-ui-providers) | Web UI (browser) | Perplexity account | Yes | +| [Grok](docs/python.md#web-ui-providers) | Web UI (browser) | X/Twitter account | Yes | +| [Ollama](docs/python.md#ollama) | Local REST | None | No | +| [DeepSeek API](docs/python.md#deepseek-api) | Cloud REST | DeepSeek key | No | -## Supported LLMs +## Quick Start -- **ChatGPT** - OpenAI's ChatGPT via web interface -- **DeepSeek** - DeepSeek Chat via web interface (https://chat-deep.ai/deepseek-chat/) -- **Perplexity** - Perplexity AI via web interface (https://www.perplexity.ai/) -- **Grok** - Grok (X.com) via web interface (https://grok.com/) +```powershell +# 1. Install everything (Python deps, Node deps, optionally Ollama + dev tools) +.\setup.ps1 -## Development Notes - -> ⚠️ **Important**: This project maintains two `package.json` files: -> - `./package.json` - For npm package installation -> - `./src/textgenhub/package.json` - For Python package dependencies -> -> When modifying Node.js dependencies or version numbers, please ensure to update both files to keep them synchronized. - - - -### Python Package -```bash -# Using pip -pip install textgenhub - -# Using poetry -poetry add textgenhub -``` - -### Node.js Package -```bash -# Using npm -npm install textgenhub - -# Using yarn -yarn add textgenhub -``` - -## Usage - -### Python - -All providers now support a unified `ask()` interface for consistency: - -```python -from textgenhub import chatgpt, deepseek, perplexity - -# Unified interface - all providers support ask() -# By default, prompts are pasted instantly (typing_speed=None) -response = chatgpt.ask("What is Python?", headless=True) -response = deepseek.ask("What is Python?", headless=True) -response = perplexity.ask("What is Python?", headless=True) - -# For character-by-character typing, set typing_speed (in seconds per character) -response = chatgpt.ask("What is Python?", typing_speed=0.05) -``` - -#### ChatGPT -```python -from textgenhub import chatgpt - -# Use the unified ask() interface -response = chatgpt.ask("What day is it today?", headless=True) -print(response) -``` - -#### DeepSeek -```python -from textgenhub import deepseek - -# Use the unified ask() interface -response = deepseek.ask("What day is it today?", headless=True) -print(response) +# 2. Try it +poetry run textgenhub ollama --prompt "Hello, world!" ``` -#### Perplexity -```python -from textgenhub import perplexity - -# Use the unified ask() interface -response = perplexity.ask("What day is it today?", headless=True) -print(response) -``` - -### Node.js - -#### ChatGPT -```javascript -const { ChatGPT } = require('textgenhub'); - -// Create a ChatGPT instance -const chatgpt = new ChatGPT(); - -// Use it in your code -chatgpt.chat("What day is it today?", { headless: true }) - .then(response => console.log(response)); -``` - -#### DeepSeek -```javascript -const { DeepSeek } = require('textgenhub'); - -// Create a DeepSeek instance -const deepseek = new DeepSeek(); - -// Use it in your code -deepseek.chat("What day is it today?", { headless: true }) - .then(response => console.log(response)); -``` - -#### Perplexity -```javascript -const { Perplexity } = require('textgenhub'); - -// Create a Perplexity instance -const perplexity = new Perplexity(); - -// Use it in your code -perplexity.chat("What day is it today?", { headless: true }) - .then(response => console.log(response)); -``` - -## Running the CLI - -### Unified CLI Interface - -TextGenHub now provides a unified CLI interface for all providers: - -```bash -# Install and use the unified CLI -poetry install -poetry run textgenhub --help - -# ChatGPT -poetry run textgenhub chatgpt --prompt "What day is it today?" - -# DeepSeek (headless browser method) -poetry run textgenhub deepseek --prompt "What day is it today?" +## Switch Providers -# Perplexity (headless browser method) -poetry run textgenhub perplexity --prompt "What day is it today?" +Same CLI, different provider: -# Grok (headless browser method) -poetry run textgenhub grok --prompt "What day is it today?" +```powershell +poetry run textgenhub ollama --prompt "hi"                         # local model +poetry run textgenhub deepseek-api --prompt "hi"                   # cloud API +poetry run textgenhub chatgpt --prompt "hi"                        # browser automation ``` -#### CLI Options +## Docs -- `--prompt`: The text prompt to send to the LLM (required for most providers) -- `--headless`: Run browser in headless mode (default: true) -- `--output-format`: Output format - `json` (default), `html`, or `raw` (ChatGPT: json/html/raw; others: json/html) -- `--timeout`: Timeout in seconds for extension mode (ChatGPT only, default: 120) -- `--typing-speed`: Typing speed in seconds per character (default: None for instant paste, > 0 for character-by-character typing) -- `--session`: Explicit session index to use (ChatGPT only, see `sessions list` command) -- `--close`: Close browser session after completion (ChatGPT only, default: keep open) - -#### Session management (ChatGPT only) - -```bash -# List all available ChatGPT sessions -poetry run textgenhub sessions list - -# Show the path to the central sessions.json file -poetry run textgenhub sessions path - -# Create a new ChatGPT session with auto-assigned index (opens browser for login) -poetry run textgenhub sessions init - -# Create or regenerate a specific session index (opens browser for login) -poetry run textgenhub sessions init --index 0 -poetry run textgenhub sessions init --index 2 - -# Get help on available session commands -poetry run textgenhub sessions --help -poetry run textgenhub sessions init --help -``` - -The ChatGPT provider supports browser profile isolation with intelligent session management. Sessions maintain conversation continuity and can be explicitly targeted with `--session INDEX`. - -### Recovering or Moving Sessions - -If you lose your sessions, move to a new device, or need to recreate your environment, follow these steps to rebuild your session library: - -1. **(Optional) Clear existing corrupted sessions**: - If your sessions are in a bad state, you can start fresh by removing the central sessions file: - ```bash - # On Windows (PowerShell) - powershell -Command "Remove-Item (poetry run textgenhub sessions path)" - - # On Linux/macOS - rm $(poetry run textgenhub sessions path) - ``` - -2. **Re-initialize specific sessions**: - Run the `init` command for each session index you wish to restore. This will open a browser window for you to log in: - ```bash - # Initialize session 0 (the default session) - poetry run textgenhub sessions init --index 0 - - # Initialize additional sessions if needed - poetry run textgenhub sessions init --index 1 - poetry run textgenhub sessions init --index 2 - ``` - -3. **Verify the rebuild**: - ```bash - poetry run textgenhub sessions list - ``` - -**Session Storage Policy**: `sessions.json` is stored centrally on your system to ensure consistency across all projects using `textgenhub`: `%LOCALAPPDATA%\textgenhub\sessions.json`. - -This central location prevents the need to copy `sessions.json` between projects or virtual environments. If a local `sessions.json` exists in your project directory, it will be automatically migrated to the central location on first use. - -#### CLI examples - -```bash -# ChatGPT - JSON output (default) -poetry run textgenhub chatgpt --prompt "Explain quantum computing" - -# ChatGPT with session-based provider - HTML output -poetry run textgenhub chatgpt --prompt "Explain quantum computing" --output-format html - -# ChatGPT with session-based provider - Raw text output -poetry run textgenhub chatgpt --prompt "Explain quantum computing" --output-format raw - -# ChatGPT with character-by-character typing (0.05 seconds per character) -poetry run textgenhub chatgpt --prompt "Explain quantum computing" --typing-speed 0.05 - -# ChatGPT using specific session (session index 1) -poetry run textgenhub chatgpt --prompt "Explain quantum computing" --session 1 - -# Regenerate session 0 if it's broken -poetry run textgenhub sessions init --index 0 - -# ChatGPT with automatic closing after receiving the response -poetry run textgenhub chatgpt --prompt "Explain quantum computing" --close - -# Closing doesn't necessarily need a prompt, we can close a specific session as well -poetry run textgenhub chatgpt --close --session 0 - -# DeepSeek - JSON output -poetry run textgenhub deepseek --prompt "What is machine learning?" - -# Perplexity - JSON output -poetry run textgenhub perplexity --prompt "What is the capital of France?" - -# Grok - JSON output -poetry run textgenhub grok --prompt "Tell me a joke" -``` - -#### JSON Output Format - -When using `--output-format json` (default), the CLI returns structured JSON: - -```json -{ - "provider": "chatgpt", - "method": "headless", - "timestamp": "2025-11-13T20:14:44.465890", - "prompt": "What is 2 + 2?", - "response": "2 + 2 equals 4.", - "html": "" -} -``` - -#### HTML Output Format - -When using `--output-format html`, the CLI returns raw HTML content directly, perfect for downstream processing: - -```bash -# Get HTML content for further processing -HTML_CONTENT=$(poetry run textgenhub chatgpt --prompt "Generate a report" --output-format html) -``` - -#### Raw Output Format - -When using `--output-format raw` (ChatGPT session-based provider only), the CLI returns plain text content without any formatting or metadata: - -```bash -# Get plain text response only -poetry run textgenhub chatgpt --prompt "Summarize the Ukraine crisis" --output-format raw -``` +| Topic | File | +|---|---| +| Python usage (all providers) | [docs/python.md](docs/python.md) | +| Node.js usage | [docs/nodejs.md](docs/nodejs.md) | +| CLI reference (flags, examples) | [docs/cli.md](docs/cli.md) | +| ChatGPT session management | [docs/sessions.md](docs/sessions.md) | +| Development & architecture | [docs/development.md](docs/development.md) | diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..af4b2a9 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,105 @@ +# CLI Reference + +## Usage + +```bash +poetry run textgenhub [options] +``` + +## Providers + +| Provider | Command | Requires | +|---|---|---| +| ChatGPT | `textgenhub chatgpt` | Node.js + Chrome | +| DeepSeek (browser) | `textgenhub deepseek` | Node.js + Chrome | +| Perplexity | `textgenhub perplexity` | Node.js + Chrome | +| Grok | `textgenhub grok` | Node.js + Chrome | +| Ollama | `textgenhub ollama` | Ollama running | +| DeepSeek API | `textgenhub deepseek-api` | API key | + +## All Options + +| Option | Description | Providers | +|---|---|---| +| `--prompt` | The text prompt to send | All | +| `--headless` | Run browser headless (default: true) | Web UI | +| `--output-format` | `json` (default), `html`, or `raw` | Web UI / Ollama | +| `--timeout` | Timeout in seconds | All | +| `--typing-speed` | Seconds per character | Web UI | +| `--session` | Session index | ChatGPT | +| `--close` | Close session after response | ChatGPT | +| `--model` | Model name | Ollama / DeepSeek API | +| `--host` | Ollama host | Ollama | +| `--port` | Ollama port | Ollama | +| `--system-prompt` | System prompt | Ollama / DeepSeek API | +| `--api-key` | DeepSeek API key | DeepSeek API | +| `--temperature` | Sampling temperature (0.0–2.0) | DeepSeek API | +| `--max-tokens` | Max output tokens | DeepSeek API | + +## Examples + +### ChatGPT + +```bash +# Basic +poetry run textgenhub chatgpt --prompt "Explain quantum computing" + +# Character-by-character typing +poetry run textgenhub chatgpt --prompt "Explain quantum computing" --typing-speed 0.05 + +# Specific session with auto-close +poetry run textgenhub chatgpt --prompt "Explain quantum computing" --session 1 --close +``` + +### Ollama + +```bash +# JSON output (default) +poetry run textgenhub ollama --prompt "What is machine learning?" --model llama3 + +# Raw text output +poetry run textgenhub ollama --prompt "What is machine learning?" --model llama3 --output-format raw + +# With system prompt +poetry run textgenhub ollama --prompt "Translate to French: Hello world" \ + --model llama3 --system-prompt "You are a translator" +``` + +### DeepSeek API + +```bash +# Basic +DEEPSEEK_API_KEY=sk-... poetry run textgenhub deepseek-api --prompt "What is machine learning?" + +# Custom model and temperature +DEEPSEEK_API_KEY=sk-... poetry run textgenhub deepseek-api --prompt "Write Python code" \ + --model deepseek-coder --temperature 0.7 --max-tokens 500 + +# Raw output +DEEPSEEK_API_KEY=sk-... poetry run textgenhub deepseek-api --prompt "Tell me a joke" --output-format raw +``` + +## Output Formats + +### JSON (default) + +```json +{ + "provider": "chatgpt", + "method": "headless", + "timestamp": "2025-11-13T20:14:44.465890", + "prompt": "What is 2 + 2?", + "response": "2 + 2 equals 4.", + "html": "" +} +``` + +### HTML / Raw + +```bash +# HTML output +poetry run textgenhub chatgpt --prompt "Generate a report" --output-format html + +# Raw text (no metadata) +poetry run textgenhub chatgpt --prompt "Summarize the crisis" --output-format raw +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..db1dec4 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,92 @@ +# Development + +## Project Structure + +``` +src/textgenhub/ +├── webui/ # Browser automation (ChatGPT, DeepSeek, Perplexity, Grok) +│ ├── chatgpt/ +│ ├── deepseek/ +│ ├── grok/ +│ └── perplexity/ +├── local/ # Local inference (Ollama) +│ └── ollama/ +├── api/ # API-key providers (DeepSeek API) +│ └── deepseek/ +├── core/ # Shared provider base class +└── utils/ # Shared utilities +``` + +## Package.json + +This project maintains two `package.json` files: + +- `./package.json` — npm package manifest +- `./src/textgenhub/package.json` — Node.js dependencies for web UI providers + +When modifying Node.js dependencies, update **both** files. + +## Provider Architecture + +Each provider has a Python wrapper that calls a Node.js CLI script: + +``` +provider.py ──> node provider_cli.js ──> puppeteer / REST API +``` + +The base class (`SimpleProvider`) handles subprocess invocation. Path resolution is strict: + +```python +provider = SimpleProvider("chatgpt", "chatgpt_cli.js", script_dir=Path(__file__).parent) +``` + +## Adding a New Provider + +1. Create `src/textgenhub/webui//` (or `local/` / `api/`) +2. Add `__init__.py` +3. Create `.py` with an `ask()` function using `SimpleProvider` +4. Create `_cli.js` (Node.js script, outputs JSON to stdout) +5. Export from `src/textgenhub/__init__.py` +6. Add CLI subparser in `src/textgenhub/cli.py` + +## Testing + +```bash +poetry install --with dev +pytest +``` + +## Dev Tools + +Run `.\setup.ps1` and answer "y" to the dev tools prompt, or install manually: + +### Ponytail (AGENTS.md) + +The `AGENTS.md` file defines the project's coding philosophy — "lazy senior dev mode." It's not a package; it's a constraint file that tells agents (and humans) to prefer minimal code, no over-engineering, and stdlib over dependencies. It's version-controlled in the repo root. + +### Graphify + +Graphify generates a code dependency graph (`graphify-out/`) to help agents understand the codebase structure. It's a compiled Windows binary. + +```powershell +# Install (manual download) +# Download from: https://github.com/anthropics/graphify/releases +# Place graphify.exe somewhere in PATH + +# Generate/update the code graph +graphify update . + +# Output lives in graphify-out/ (gitignored) +``` + +### Pre-commit Hooks + +```powershell +poetry run pre-commit install +``` + +## CI + +- **Nightly regression tests** run against all providers +- **Codecov** tracks Python test coverage +- GitHub Actions workflows in `.github/workflows/` diff --git a/docs/nodejs.md b/docs/nodejs.md new file mode 100644 index 0000000..eaade2b --- /dev/null +++ b/docs/nodejs.md @@ -0,0 +1,47 @@ +# Node.js Usage + +## Quick Start + +```javascript +const { ChatGPT, DeepSeek, Perplexity, Grok } = require('textgenhub'); +``` + +## ChatGPT + +```javascript +const { ChatGPT } = require('textgenhub'); + +const chatgpt = new ChatGPT(); +chatgpt.chat("What day is it today?", { headless: true }) + .then(response => console.log(response)); +``` + +## DeepSeek + +```javascript +const { DeepSeek } = require('textgenhub'); + +const deepseek = new DeepSeek(); +deepseek.chat("What day is it today?", { headless: true }) + .then(response => console.log(response)); +``` + +## Perplexity + +```javascript +const { Perplexity } = require('textgenhub'); + +const perplexity = new Perplexity(); +perplexity.chat("What day is it today?", { headless: true }) + .then(response => console.log(response)); +``` + +## Grok + +```javascript +const { Grok } = require('textgenhub'); + +const grok = new Grok(); +grok.chat("What day is it today?", { headless: true }) + .then(response => console.log(response)); +``` diff --git a/docs/python.md b/docs/python.md new file mode 100644 index 0000000..d1f0ca6 --- /dev/null +++ b/docs/python.md @@ -0,0 +1,146 @@ +# Python Usage + +## Quick Start + +```python +from textgenhub import chatgpt, ollama, deepseek + +# Web UI provider (browser automation) +response = chatgpt.ask("What is Python?", headless=True) + +# Local provider (direct REST, no API key) +response = ollama.ask("Explain quantum computing", model="llama3") + +# API-key provider (direct REST) +response = deepseek.ask("Write a Python function", model="deepseek-coder") +``` + +## ChatGPT + +ChatGPT is a web UI provider with session support. + +## Web UI Providers + +Supports: `chatgpt`, `deepseek`, `perplexity`, `grok` + +All providers share a unified `ask()` interface: + +```python +from textgenhub import chatgpt, deepseek, perplexity, grok + +# Default: instant paste (no typing animation) +response = chatgpt.ask("What is Python?", headless=True) + +# Character-by-character typing (seconds per character) +response = chatgpt.ask("What is Python?", typing_speed=0.05) +``` + +### ChatGPT Sessions + +```python +from textgenhub import chatgpt + +# Start a session +response = chatgpt.ask("What day is it?", headless=True, session=0, close=False) + +# Continue the same session +response = chatgpt.ask("Tell me more", session=0) + +# Close without a prompt +chatgpt.close(session=0) +``` + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `prompt` | str | required | Message to send | +| `headless` | bool | `True` | Run browser headless | +| `typing_speed` | float or None | `None` | Seconds per character (None = instant paste) | +| `timeout` | int | `120` | Seconds before timeout | +| `session` | int or None | `None` | ChatGPT session index | +| `close` | bool | `False` | Close session after response (ChatGPT only) | +| `max_trials` | int | `10` | Retry attempts on rate limit | + +## Ollama + +Install via `.\setup.ps1`. + +```python +from textgenhub import ollama, Ollama + +# One-liner (uses OLLAMA_MODEL env var or llama3) +response = ollama.ask("What is Python?") + +# Specify model +response = ollama.ask("Explain quantum computing", model="qwen2.5:3b") + +# Class-based (persistent config) +client = Ollama(model="llama3", system_prompt="You are a helpful assistant") +response = client.chat("Hello") + +# Remote Ollama instance +client = Ollama(model="llama3", host="192.168.1.50", port=11434) + +# List available models +models = ollama.list_models() +``` + +### Ollama Configuration + +| Env Var | Default | Description | +|---|---|---| +| `OLLAMA_MODEL` | `llama3` | Default model name | +| `OLLAMA_HOST` | `localhost` | Server host | +| `OLLAMA_PORT` | `11434` | Server port | + +### Ollama.chat() Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `prompt` | str | required | Message to send | +| `temperature` | float | `0.7` | Sampling temperature | +| `max_tokens` | int | `None` | Max output tokens | +| `system_prompt` | str or None | `None` | System prompt | + +## DeepSeek API + +Get a key at [platform.deepseek.com](https://platform.deepseek.com). + +```python +from textgenhub import deepseek, DeepSeekAPI + +# Inline key (one-off) +response = deepseek.ask("hi", api_key="sk-your-key") + +# Environment variable (recommended) +import os +os.environ["DEEPSEEK_API_KEY"] = "sk-your-key" +response = deepseek.ask("Explain neural nets") + +# Class-based +client = DeepSeekAPI( + model="deepseek-reasoner", + system_prompt="You are a math tutor.", +) +response = client.chat("Solve: x^2 + 3x + 2 = 0", temperature=0.3, max_tokens=500) + +# Custom endpoint (proxy / self-hosted) +response = deepseek.ask("hi", base_url="https://your-proxy/v1") +``` + +### Available Models + +| Model | Best for | +|---|---| +| `deepseek-chat` | General tasks (default) | +| `deepseek-coder` | Code generation & debugging | +| `deepseek-reasoner` | Math, logic, reasoning | + +## Node.js Providers + +```python +from textgenhub import webui + +# Automatically installs Node dependencies on first import +``` diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 0000000..9a75e4e --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,43 @@ +# Session Management (ChatGPT) + +Sessions persist login state so you don't need to sign in every time. + +## Session Storage + +Sessions are stored centrally at: + +`%LOCALAPPDATA%\textgenhub\sessions.json` + +## CLI Commands + +```bash +# List all sessions +poetry run textgenhub sessions list + +# Show the sessions file path +poetry run textgenhub sessions path + +# Create a new session (opens browser for login) +poetry run textgenhub sessions init + +# Create a specific session index +poetry run textgenhub sessions init --index 0 +poetry run textgenhub sessions init --index 2 +``` + +## Recovering Sessions + +If sessions are corrupted or you're moving to a new machine: + +```powershell +# 1. Clear existing sessions +Remove-Item $env:LOCALAPPDATA\textgenhub\sessions.json + +# 2. Re-initialize +poetry run textgenhub sessions init --index 0 +poetry run textgenhub sessions init --index 1 +poetry run textgenhub sessions init --index 2 + +# 3. Verify +poetry run textgenhub sessions list +``` diff --git a/package.json b/package.json index d175f37..3149bf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "textgenhub", - "version": "1.2.2", + "version": "2.0.0", "description": "Text generation hub supporting multiple LLM providers", "main": "index.js", "scripts": { diff --git a/poetry.lock b/poetry.lock index f81eaa3..3517be4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -306,6 +306,73 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.11.0,<2.12.0" pyflakes = ">=3.1.0,<3.2.0" +[[package]] +name = "graphifyy" +version = "0.8.44" +description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "graphifyy-0.8.44-py3-none-any.whl", hash = "sha256:b4e188c57e025a0f4c5039bbb88eb420326bb4f4f4355ebd6711426cb8d1048c"}, + {file = "graphifyy-0.8.44.tar.gz", hash = "sha256:bd0bd48421ddab2db1fabc833e3f1f414cd5a85d0825f47274331a23ef4dcfca"}, +] + +[package.dependencies] +networkx = ">=3.4" +numpy = ">=1.21" +rapidfuzz = ">=3.0" +tree-sitter = ">=0.23.0,<0.26" +tree-sitter-bash = ">=0.23,<0.27" +tree-sitter-c = ">=0.23,<0.25" +tree-sitter-c-sharp = ">=0.23,<0.25" +tree-sitter-cpp = ">=0.23,<0.25" +tree-sitter-elixir = ">=0.3,<0.5" +tree-sitter-fortran = ">=0.6,<0.8" +tree-sitter-go = ">=0.23,<0.26" +tree-sitter-groovy = ">=0.1,<0.3" +tree-sitter-java = ">=0.23,<0.25" +tree-sitter-javascript = ">=0.23,<0.26" +tree-sitter-json = ">=0.23,<0.26" +tree-sitter-julia = ">=0.23,<0.25" +tree-sitter-kotlin = ">=1.0,<2.0" +tree-sitter-lua = ">=0.2,<0.6" +tree-sitter-objc = ">=3.0,<4.0" +tree-sitter-php = ">=0.23,<0.25" +tree-sitter-powershell = ">=0.26,<0.28" +tree-sitter-python = ">=0.23,<0.26" +tree-sitter-ruby = ">=0.23,<0.25" +tree-sitter-rust = ">=0.23,<0.25" +tree-sitter-scala = ">=0.23,<0.27" +tree-sitter-swift = ">=0.7,<0.9" +tree-sitter-typescript = ">=0.23,<0.25" +tree-sitter-verilog = ">=1.0,<2.0" +tree-sitter-zig = ">=1.0,<2.0" + +[package.extras] +all = ["anthropic", "boto3", "falkordb", "faster-whisper ; python_version >= \"3.11\"", "graspologic ; python_version < \"3.13\"", "jieba", "markdownify", "matplotlib", "mcp", "neo4j", "numpy (>=2.0) ; python_version >= \"3.13\"", "openai", "openpyxl", "pypdf (>=6.12.0)", "python-docx", "tiktoken", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-sql", "watchdog", "yt-dlp (>=2026.6.9)"] +anthropic = ["anthropic"] +bedrock = ["boto3"] +chinese = ["jieba"] +dm = ["tree-sitter-dm"] +falkordb = ["falkordb"] +gemini = ["openai", "tiktoken"] +google = ["openpyxl"] +kimi = ["openai", "tiktoken"] +leiden = ["graspologic ; python_version < \"3.13\""] +mcp = ["mcp"] +neo4j = ["neo4j"] +office = ["openpyxl", "python-docx"] +ollama = ["openai"] +openai = ["openai", "tiktoken"] +pdf = ["markdownify", "pypdf (>=6.12.0)"] +postgres = ["psycopg[binary]"] +sql = ["tree-sitter-sql"] +svg = ["matplotlib", "numpy (>=2.0) ; python_version >= \"3.13\""] +terraform = ["tree-sitter-hcl"] +video = ["faster-whisper ; python_version >= \"3.11\"", "yt-dlp (>=2026.6.9)"] +watch = ["watchdog"] + [[package]] name = "identify" version = "2.6.15" @@ -360,6 +427,29 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "networkx" +version = "3.6" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"}, + {file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"}, +] + +[package.extras] +benchmarking = ["asv", "virtualenv"] +default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] +developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] +doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] +example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] +extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] +release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] +test-extras = ["pytest-mpl", "pytest-randomly"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -372,6 +462,88 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "numpy" +version = "2.4.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, + {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, + {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, + {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, + {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, + {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, + {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, + {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, + {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, + {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, + {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, + {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, + {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, + {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, + {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, + {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, + {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, + {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, + {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, + {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, +] + [[package]] name = "packaging" version = "25.0" @@ -602,6 +774,102 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "rapidfuzz" +version = "3.14.5" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe"}, + {file = "rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819"}, + {file = "rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9"}, + {file = "rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35"}, + {file = "rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a"}, + {file = "rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502"}, + {file = "rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66"}, + {file = "rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5"}, + {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf"}, + {file = "rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "requests" version = "2.32.5" @@ -624,6 +892,592 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "tree-sitter" +version = "0.25.2" +description = "Python bindings to the Tree-sitter parsing library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20"}, + {file = "tree_sitter-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72a510931c3c25f134aac2daf4eb4feca99ffe37a35896d7150e50ac3eee06c7"}, + {file = "tree_sitter-0.25.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44488e0e78146f87baaa009736886516779253d6d6bac3ef636ede72bc6a8234"}, + {file = "tree_sitter-0.25.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2f8e7d6b2f8489d4a9885e3adcaef4bc5ff0a275acd990f120e29c4ab3395c5"}, + {file = "tree_sitter-0.25.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b570690f87f1da424cd690e51cc56728d21d63f4abd4b326d382a30353acc7"}, + {file = "tree_sitter-0.25.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a0ec41b895da717bc218a42a3a7a0bfcfe9a213d7afaa4255353901e0e21f696"}, + {file = "tree_sitter-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:7712335855b2307a21ae86efe949c76be36c6068d76df34faa27ce9ee40ff444"}, + {file = "tree_sitter-0.25.2-cp310-cp310-win_arm64.whl", hash = "sha256:a925364eb7fbb9cdce55a9868f7525a1905af512a559303bd54ef468fd88cb37"}, + {file = "tree_sitter-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ca72d841215b6573ed0655b3a5cd1133f9b69a6fa561aecad40dca9029d75b"}, + {file = "tree_sitter-0.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0351cfe5022cec5a77645f647f92a936b38850346ed3f6d6babfbeeeca4d26"}, + {file = "tree_sitter-0.25.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1799609636c0193e16c38f366bda5af15b1ce476df79ddaae7dd274df9e44266"}, + {file = "tree_sitter-0.25.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e65ae456ad0d210ee71a89ee112ac7e72e6c2e5aac1b95846ecc7afa68a194c"}, + {file = "tree_sitter-0.25.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49ee3c348caa459244ec437ccc7ff3831f35977d143f65311572b8ba0a5f265f"}, + {file = "tree_sitter-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:56ac6602c7d09c2c507c55e58dc7026b8988e0475bd0002f8a386cce5e8e8adc"}, + {file = "tree_sitter-0.25.2-cp311-cp311-win_arm64.whl", hash = "sha256:b3d11a3a3ac89bb8a2543d75597f905a9926f9c806f40fcca8242922d1cc6ad5"}, + {file = "tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960"}, + {file = "tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c"}, + {file = "tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99"}, + {file = "tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9"}, + {file = "tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac"}, + {file = "tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897"}, + {file = "tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5"}, + {file = "tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd"}, + {file = "tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601"}, + {file = "tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053"}, + {file = "tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614"}, + {file = "tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae"}, + {file = "tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b"}, + {file = "tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8"}, + {file = "tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0"}, + {file = "tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87"}, + {file = "tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab"}, + {file = "tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358"}, + {file = "tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0"}, + {file = "tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721"}, + {file = "tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f"}, +] + +[package.extras] +docs = ["sphinx (>=8.1,<9.0)", "sphinx-book-theme"] +tests = ["tree-sitter-html (>=0.23.2)", "tree-sitter-javascript (>=0.23.1)", "tree-sitter-json (>=0.24.8)", "tree-sitter-python (>=0.23.6)", "tree-sitter-rust (>=0.23.2)"] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +description = "Bash grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_bash-0.25.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0e6235f59e366d220dde7d830196bed597d01e853e44d8ccd1a82c5dd2500acf"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f4a34a6504c7c5b2a9b8c5c4065531dea19ca2c35026e706cf2eeeebe2c92512"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e76c4cfb20b076552406782b7f8c2a3946835993df0a44df006de54b7030c7dc"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f484c4bb8796cde7a87ca351e6116f09653edac0eb3c6d238566359dd28b117"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5e76af6df46d958c7f5b6d5884c9743218e3902a00ccb493ec92728b1084430b"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a3332d71c7b7d5f78259b19d02d0ea111fcb82b72712ee4a93aaa5b226d3f0a8"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:52a6802d9218f86278aa3e8b459c3abdad67eed0fde1f9f13aca5b6c634217a6"}, + {file = "tree_sitter_bash-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:59115057ec2bae319e8082ff29559861045002964c3431ccb0fc92aa4bc9bccb"}, + {file = "tree_sitter_bash-0.25.1.tar.gz", hash = "sha256:bfc0bdaa77bc1e86e3c6652e5a6e140c40c0a16b84185c2b63ad7cd809b88f14"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +description = "C grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_c-0.24.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d4579a8b54f0a442f903d88d3304cab77cd5c2031d4015baa4f2f8e15d6dcb7"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c098bedcd5ac86ff93fa734d51d1dd86aed40fd5ed7d634c7af11380a0469969"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82842c5a5f2acd93f4de10038c33ac179c8979defc39376f990348d6289e933b"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2b42e8e22202c251f8629306f9321233542e07a6e01611b5fe83489272143eb"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-win_amd64.whl", hash = "sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1"}, + {file = "tree_sitter_c-0.24.2-cp310-abi3-win_arm64.whl", hash = "sha256:4a2f4371cd816cc3153458f69062135ebb2ea5f275ddd90494e5c823d778204a"}, + {file = "tree_sitter_c-0.24.2.tar.gz", hash = "sha256:1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +description = "C# grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:61e1981cf21b09ee547b9c4c68e64fb4394325f8fc8d5f6d50d41471eba923ea"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a75994a11f6fed3f5b8c36ad6a00e5dc43205bd912c43af3a2a54fdf649664eb"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aa88a780204cd153c4c1ae2d59c654cee1402212fa0d069823d6d34301587438"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea38fb095d85d360dc5a0bec2fa605e496228876f798c9e089d5f0e72bcef46"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:05a9256415e7f24d4f133133794a9c224c60d19f677a04e2f6a94c25090b6d65"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8636dc70b5a373c35c1036ed5de98e801f2e4d105ae41e2e20b6804c36e3bf33"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-win_amd64.whl", hash = "sha256:41a28cfa3d9ea50f5629e44550a03188c8fbd5079803dfc03554b6fd594b33fa"}, + {file = "tree_sitter_c_sharp-0.23.5-cp310-abi3-win_arm64.whl", hash = "sha256:2de4ebf95ddc2e92cd3105c8a8e0e7ec646bc82f52bfaf2f3acec0fa2401ec09"}, + {file = "tree_sitter_c_sharp-0.23.5.tar.gz", hash = "sha256:2635c7d5ec93e59f2e831b571bed99c4cc68a5d183a0994020aa769e1b990a71"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +description = "C++ grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aacb1759f0efd9dbc25bd8ee88184a340483018869f75412d9c3bc32c039a520"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc3c404d9f0cbd87951213a85440afbf4c31e718f8d907fa9ee12bea4b8d276f"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc43ddf1279d5d5a4ef190373f4cb16522801bec4492bcd4754edf2aeba2b7b"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773d2cafc08bbc0f998687fa33f42f378c1a371cdb582870c4d13abb06092706"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:247d127f0eb6574b0f6b30c0151e0bd0774e2e7acf9c558bdf9fbb8adc2e80c0"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-win_amd64.whl", hash = "sha256:68606a45bea92669d155399e1239f771a7767d8683cd8f8e30e7d813107030ca"}, + {file = "tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281"}, + {file = "tree_sitter_cpp-0.23.4.tar.gz", hash = "sha256:6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +description = "Elixir grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:514078a2f68d27da9a1e6b6e9601b8456faba6260ecfa252e898a848c4f8584d"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:015f537731af690cfa238b0fb76a8af4f0d1a2c54a38563f159926d2967ce650"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ebfe3491a3d00ac50b12a3bfcabb1c564f3809ed8a095099fe87f49d6b3987e6"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1159057f914d4468fc53cb9d7e8369f8a7826e1d07765bb53fbf391e6058863"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d6187b4d592bfb31760799ac6ddbb5a2457ba0a612de43d77bcbcd5f00cc49bf"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5d5d8aa077ff244d24406b1fb5a17c03a2919c5183c51ca35654870d08b239b"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-win_amd64.whl", hash = "sha256:c0b5df229405d42ba5c94254d92e414b1f200be8422561d243ae5b3558e84f76"}, + {file = "tree_sitter_elixir-0.3.5-cp39-abi3-win_arm64.whl", hash = "sha256:fee42b90962e1e131cc31720f3038410291b2196ed231e00c1721597fc0567df"}, + {file = "tree_sitter_elixir-0.3.5.tar.gz", hash = "sha256:ead089393b1ce732304e6b6fb0bc0ab79e3295663d697be025bd49f0f367b74d"}, +] + +[package.extras] +core = ["tree-sitter (>=0.23,<1.0)"] + +[[package]] +name = "tree-sitter-fortran" +version = "0.6.0" +description = "Fortran grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b6495c4c25cf68785ffd30e615b5481219415761ca66dde14a9577d03075714d"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:a0fe5929fd91d245aba5a3b414399a296fb9924942a549190cee226e5b1ec96c"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd7b179305db93ffe8435ee42f6895e76677744721707b3f2f328a92dd4f61e"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac4800b4abc1b25e6e7ab4a3f2eae274c5b19107beb18d3a473c0f67509c7486"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f9ba6ca864d39f5df2787ed58222ee25570c47c659df0d7b5753a8c4dc3e29d"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9348398630d6d7e5e3588a14517f889fc0315c33b059e004d0468000db2a7206"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:cccd5bce1cdebcf34d3a130ecf4944bc409ddc93096317e3249838ffdaf927eb"}, + {file = "tree_sitter_fortran-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:45b0e226325e626101949d6aafcf0422fc210c3cf3ae9b9a2281b41f47d9cc20"}, + {file = "tree_sitter_fortran-0.6.0.tar.gz", hash = "sha256:65fea540148ae431335b3920267dffaeeb157ef2b21c0716798c751f6a9e193b"}, +] + +[package.extras] +core = ["tree-sitter (>=0.21,<1.0)"] + +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +description = "Go grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_go-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b852993063a3429a443e7bd0aa376dd7dd329d595819fabf56ac4cf9d7257b54"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:503b81a2b4c31e302869a1de3a352ad0912ccab3df9ac9950197b0a9ceeabd8f"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04b3b3cb4aff18e74e28d49b716c6f24cb71ddfdd66768987e26e4d0fa812f74"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:148255aca2f54b90d48c48a9dbb4c7faad6cad310a980b2c5a5a9822057ed145"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4d338116cdf8a6c6ff990d2441929b41323ef17c710407abe0993c13417d6aad"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5608e089d2a29fa8d2b327abeb2ad1cdb8e223c440a6b0ceab0d3fa80bdeebae"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:30d4ada57a223dfc2c32d942f44d284d40f3d1215ddcf108f96807fd36d53022"}, + {file = "tree_sitter_go-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:d5d62362059bf79997340773d47cc7e7e002883b527a05cca829c46e40b70ded"}, + {file = "tree_sitter_go-0.25.0.tar.gz", hash = "sha256:a7466e9b8d94dda94cae8d91629f26edb2d26166fd454d4831c3bf6dfa2e8d68"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-groovy" +version = "0.1.2" +description = "Groovy grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27adb7a4077511782dbd94a12f4635dfb52ccb88f734fe1569393e2d28b18bbd"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:db35a5bdceb826382c7f52d33db0b2075217473f698daf77eb8d4e557a161d51"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cdb4c62284f19fbfdd4900e816c3e8604672de107e4e52a8e65b663f368b4cb"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e938e9c2cd5fdb08fd1b28d7d621d15ea959a17a4bc0b77833e07a94fe7d263"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:beda8f7b0c596e20cabc75fc076a3e6e9af8318e30c1869df6a036183a8cdd33"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:bb8b20e2c92a18509ad3b830aeba9f5754778903e7dfd6999c3efb3c79c43d76"}, + {file = "tree_sitter_groovy-0.1.2-cp39-abi3-win_arm64.whl", hash = "sha256:1942a9a1b22e154da9bbf1b03e6b4dbec4211b1109d24bcf4c12b006cbc04037"}, + {file = "tree_sitter_groovy-0.1.2.tar.gz", hash = "sha256:49b004c4ae946d3f01a602f325cd8996423e034e5b3ad36fc34a1d1e42afa8da"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +description = "Java grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_java-0.23.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:355ce0308672d6f7013ec913dee4a0613666f4cda9044a7824240d17f38209df"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:24acd59c4720dedad80d548fe4237e43ef2b7a4e94c8549b0ca6e4c4d7bf6e69"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9401e7271f0b333df39fc8a8336a0caf1b891d9a2b89ddee99fae66b794fc5b7"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:370b204b9500b847f6d0c5ad584045831cee69e9a3e4d878535d39e4a7e4c4f1"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aae84449e330363b55b14a2af0585e4e0dae75eb64ea509b7e5b0e1de536846a"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-win_amd64.whl", hash = "sha256:1ee45e790f8d31d416bc84a09dac2e2c6bc343e89b8a2e1d550513498eedfde7"}, + {file = "tree_sitter_java-0.23.5-cp39-abi3-win_arm64.whl", hash = "sha256:402efe136104c5603b429dc26c7e75ae14faaca54cfd319ecc41c8f2534750f4"}, + {file = "tree_sitter_java-0.23.5.tar.gz", hash = "sha256:f5cd57b8f1270a7f0438878750d02ccc79421d45cca65ff284f1527e9ef02e38"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +description = "JavaScript grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c"}, + {file = "tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b"}, + {file = "tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +description = "JSON grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_json-0.24.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59ac06c6db1877d0e2076bce54a5fddcdd2fc38ca778905662e80fa9ffcea2ab"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:62b4c45b561db31436a81a3f037f71ec29049f4fc9bf5269b6ec3ebaaa35a1cd"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8627f7d375fda9fc193ebee368c453f374f65c2f25c58b6fea4e6b49a7fccbc"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cca779872f7278f3a74eb38533d34b9c4de4fd548615e3361fa64fe350ad0a"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:deeb45850dcc52990fbb52c80196492a099e3fa3512d928a390a91cf061068cc"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-win_amd64.whl", hash = "sha256:e4849a03cd7197267b2688a4506a90a13568a8e0e8588080bd0212fcb38974e3"}, + {file = "tree_sitter_json-0.24.8-cp39-abi3-win_arm64.whl", hash = "sha256:591e0096c882d12668b88f30d3ca6f85b9db3406910eaaab6afb6b17d65367dd"}, + {file = "tree_sitter_json-0.24.8.tar.gz", hash = "sha256:ca8486e52e2d261819311d35cf98656123d59008c3b7dcf91e61d2c0c6f3120e"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-julia" +version = "0.23.1" +description = "Julia grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_julia-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4bd4d8e76ab780a2de9af90cefada494cb174991d74993b6a243f28081e9432b"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8197c8d9b0cb51421aa2832f3fb539504d7b514cbb1fc79130bb1445c0b4a457"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7708a4a01831dd7cb7e6ee25146e654a0bf89077e85ffe8b5025b63a302af145"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d4f6ae938198fc0be9b6ea76313ade24fcdb89be01a791e0cc90c88fae5743d"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8aa8e959e73158632687423f4c6c61aa52dea65a451220e3e0223b67149a046"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:13031aa4c9ac7d0665aa3ecd9fbc6f9c6afd601c68f6ae67a8eeaca01465aeed"}, + {file = "tree_sitter_julia-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:673ad3079f2328c28affbee5dbedb63c7e6dab248579aabdb813bc7b862a0261"}, + {file = "tree_sitter_julia-0.23.1.tar.gz", hash = "sha256:07607c4fc902b21e6821622f56b08aa2321b921fe0644e2ab4aba1747e6c8808"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-kotlin" +version = "1.1.0" +description = "Kotlin grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6cca5ef06d090e8494ac1d9f0aac71ed32207d412766b5df7da00d94334181a2"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:910b41a580dae00d319e555075f3886a41386d1067931b14c7de504eeae3ae2a"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:906e5444ebb01db439cb3ad65913598a4ea957b0e068aa973265926a17eb00e0"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a92afe24b634cf914c5812af0f5c53184b1c18bdf6ee5505c83afac81f6bf6c"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5960034a5c5bcc7ccb21dc7a29e4267ac4f0ef37884f39d75695eac7f004deff"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:d4d3f330f515ba8b91da04a5335eb9ff3ce071c7b7855958912f2560f6e14976"}, + {file = "tree_sitter_kotlin-1.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:e030f127a7d07952907adb9070248bd42fb86dc76fd92744727551b50e131ee7"}, + {file = "tree_sitter_kotlin-1.1.0.tar.gz", hash = "sha256:322a35bdae75e25ae64dae6027be609c5422fab282084117816c4ebcda6168da"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +description = "Lua grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_lua-0.5.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cc4f2eb734dc9223bf96c0eeffa78a9485db207d00841e27e52c8b036f2164f7"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c14714ad395c4166566f3e4dd0cc0979411684cbcd23702e3c631c3e6eae84fd"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ec448c854fea32414a0449147d648bc5baddf7a0357008c4abe3269db35370a"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b02f057a997e618c5b1b03a5cef9dd6c2673043d396ca86edba372728f17ef53"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a048571f55a3dd30c94e2313091274338284cab23e757c181e4961c185ba9d0"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:922a5a3d0fec8af373cab504cbcd9abeeebb212d454f54163591c50c183466be"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-win_amd64.whl", hash = "sha256:ace3dd61218124ee08410a55601cb5fbbb00be3ee004b30e705cef9ef25165a9"}, + {file = "tree_sitter_lua-0.5.0-cp310-abi3-win_arm64.whl", hash = "sha256:8488f3bea40779896f5771bcfcdc26900eb21e94f6658eb68a848fc37dd39221"}, + {file = "tree_sitter_lua-0.5.0.tar.gz", hash = "sha256:0e46356038ccb8ce1049289104c56230003448309a335f2e353f1edc7b373552"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-objc" +version = "3.0.2" +description = "Objective-C grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_objc-3.0.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bd25b3c4ca99263c0898aa7a362a1b8d9bb642692ae9ddd357755586019b1544"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:9fa8b1221d2651a51cf42e1551c0804e9f48707da70f41f3195910c599b5522b"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30b6f9cd49593bac50161a6de6e1b8d591b318d64b33b8bde5385faa05461084"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e71282ac9c096a966bf2fa6a4ecdbea4bd037d3e01ea4aa9bbc64d9a4c0022f6"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d288d5ad4951fa31eeaf39972b39b41694eec8cc70739d48e745357c2e2c4aad"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:f3c93e991a86e96b8996cc735a4b31b38c65820913bf5a96904d07a51a8d9423"}, + {file = "tree_sitter_objc-3.0.2-cp39-abi3-win_arm64.whl", hash = "sha256:9a99d9b81a4e507bd33329be136928b3ebe424ce8b9d6b8a8339083ceb453b5b"}, + {file = "tree_sitter_objc-3.0.2.tar.gz", hash = "sha256:ac55aefe8a4f3ea6f1da2a2e05372a4f37100001934e36a81e0f96c4c6252809"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-php" +version = "0.24.1" +description = "PHP grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_php-0.24.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:d56e2dcf025450f84a2cdbf4b18a09e6cb88b92e9e6858e63de3d4133ab2e43e"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:29759c67d4c27a68c227ed82c0b7e4699617b1bd23757d50c081f81a12b4f80d"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b89832ac09f078eed2acd88598838bc51012224cbcebb916dbb6a37e74357e"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a1404a30f2972498ace040b0029738b8dac45d0a12932ccb8b605eb94bafbe4"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3e96f61462a960c78e5389c7ba6c16c25e66b465c763b8e63ad66423326c2fa7"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-win_amd64.whl", hash = "sha256:1a1b65b72a8410d421f914ee13d38fd546a94d01cb834f69b27c78ba7589a5b5"}, + {file = "tree_sitter_php-0.24.1-cp310-abi3-win_arm64.whl", hash = "sha256:56a70c5ef1bddb15f220a479b2f2edf3042c764b6c443921fbd7ca9174d664e3"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-powershell" +version = "0.26.4" +description = "A Powershell grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0bf8beac7ed4501d1c52456f8ae9728ab2a5a079325548b06b1bc9746655524e"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b5dde429c9de55b75906e240d6db1cf85417e2fc0a56d7b321810c2cd4cf3f98"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:56508e4ac7aad1e3b26f2ef96b8d2b60b149c4efa0c23742e91e809a11db73ee"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0989b221ce6cc1dfe3bc9993d3ca1ee96f3ca62173423b9a332a61c5afa3c12"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1170665958ed29abe015ad294408f15b1f76e5d52e0b96e7718ffbf340b9670c"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b2222e192edba88930b89ed5e5da66c75ea21a064768a10261c5bb01e1348de8"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-win_amd64.whl", hash = "sha256:702eadf70ec8b1fd0bbf9b4169ed58f0ee0bcab333e5103e97c0f562be299088"}, + {file = "tree_sitter_powershell-0.26.4-cp310-abi3-win_arm64.whl", hash = "sha256:5651d240387d5b9cd23ae20afdd8aad17934304a1a21d4e7825e4df38e39dda6"}, + {file = "tree_sitter_powershell-0.26.4.tar.gz", hash = "sha256:ffc7f7526420fe335cb78823b38bc8b0c27453eb974ca6056779e4cfefffa605"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +description = "Python grammar for tree-sitter" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76"}, + {file = "tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb"}, + {file = "tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac"}, +] + +[package.extras] +core = ["tree-sitter (>=0.24,<1.0)"] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +description = "Ruby grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:39f391322d2210843f07081182dbf00f8f69cfbfa4687b9575cac6d324bae443"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:aa4ee7433bd42fac22e2dad4a3c0f332292ecf482e610316828c711a0bb7f794"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62b36813a56006b7569db7868f6b762caa3f4e419bd0f8cf9ccbb4abb1b6254c"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7bcd93972b4ca2803856d4fe0fbd04123ff29c4592bbb9f12a27528bd252341"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66c65d6c2a629783ca4ab2bab539bd6f271ce6f77cacb62845831e11665b5bd3"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:02e2c19ebefe29226c14aa63e11e291d990f5b5c20a99940ab6e7eda44e744e5"}, + {file = "tree_sitter_ruby-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:ed042007e89f2cceeb1cbdd8b0caa68af1e2ce54c7eb2053ace760f90657ac9f"}, + {file = "tree_sitter_ruby-0.23.1.tar.gz", hash = "sha256:886ed200bfd1f3ca7628bf1c9fefd42421bbdba70c627363abda67f662caa21e"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +description = "Rust grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d"}, + {file = "tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227"}, + {file = "tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-scala" +version = "0.26.0" +description = "Scala grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_scala-0.26.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:80a6cf19d923dacb54621422fd806ea52b9f103ead41a279fc2278f91a488395"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7829245c660902148d06e6c9e36255d60b0feb47974c87a1d09dd2cbdbba12c8"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ec7e63b7b486a71b3799c665801a9bdfcf69417b86119ceb22630e43136082"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cff178a9310d859e819a6fe10f312b6e423d9a1d0cca5e6354a45fe0041677be"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3e5920b6ab7fd09cc91dceaaf7e12c76469990f5891337a8c0147ba25d1d55f9"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-win_amd64.whl", hash = "sha256:5e5021d78cd80debca5848af2314ed1a4b5642a7cefb10979b8e30c4945aa6dd"}, + {file = "tree_sitter_scala-0.26.0-cp39-abi3-win_arm64.whl", hash = "sha256:0eb627916fd1448657b4bcbe178e0cab8d3c114ec04aec51f0d0cd5ca2aa996e"}, + {file = "tree_sitter_scala-0.26.0.tar.gz", hash = "sha256:7f768094afbed10c07e60c202e275efc683418eeae4bdeff2c16f2ea0744939f"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +description = "Swift grammar for tree-sitter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tree_sitter_swift-0.7.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2531ec866c22ea52384e2786e07f3b2bb396c6446428a2df02cc74af3f7e6b6a"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ee627e027d0868c552beca13dcdfa9944662b126f642464c5038ee3204e68340"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f38feeb4f7350c8b30d567a0dc08bf1eeaa67c241b6888d72a45a8b1a4aa7187"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eee02fecb60a07267edd123148c583d6ec9efc5d7fcb25e53da4e56869fd4cf3"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f30c30831f090ebe245f54ddcd280d2c5f7020ba17d6bbec1662bbfae140c467"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01c1e812289a2f7f01f63627a5d94a0b57d69332e8b52624becfe79ee8061651"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-win_amd64.whl", hash = "sha256:4b1de6122cbd82b2cea6d3a295f9f5f9297601b829061119e161da17a7ba7d17"}, + {file = "tree_sitter_swift-0.7.3-cp38-abi3-win_arm64.whl", hash = "sha256:af44acc50d16f284abb607ae0cf7f81011d5566283d6c62a045a549a9331a653"}, + {file = "tree_sitter_swift-0.7.3.tar.gz", hash = "sha256:a87f1dba3050a346ee3442aad8d727afd74555dea258e31c71c7934d8c04af9b"}, +] + +[package.extras] +core = ["tree-sitter (>=0.23,<1.0)"] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +description = "TypeScript and TSX grammars for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9"}, + {file = "tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7"}, + {file = "tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d"}, +] + +[package.extras] +core = ["tree-sitter (>=0.23,<1.0)"] + +[[package]] +name = "tree-sitter-verilog" +version = "1.0.3" +description = "Verilog grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ee20fe0e21c93bf1a10e20c13cbca959eb3c9693194afb90b0567758cbf1744e"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5b9d70d86cf6913abc08766b6180e285d72848c7491a3f3f8e7bb8d8c440049d"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d617dff782a8bf56fabac8d1e782ee4ca9ebe2977682eb02d1596ff7ef89958"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:747dd7d4bc95fb389bc37225f82d16f0c40549856e9a244be3ff9d7bfe62b730"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0476d1f828954683aba38d48a7089e8b698767269950afc7615527a45de641e5"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:da82da153a8d515941da26d84d51b6b79d0fe42d0a0de19845562c3b1dd091c1"}, + {file = "tree_sitter_verilog-1.0.3-cp39-abi3-win_arm64.whl", hash = "sha256:11576eaa43f89266ab8869fb8d2fb1c22c8da74aa8dc82e67259d6560635c68f"}, + {file = "tree_sitter_verilog-1.0.3.tar.gz", hash = "sha256:d4043cba50e1ba8402396e3106e17de755c86eca311b23ab826e018ea9818984"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +description = "Zig grammar for tree-sitter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tree_sitter_zig-1.1.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e7542354a5edba377b5692b2add4f346501306d455e192974b7e76bf1a61a282"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:daa2cdd7c1a2d278f2a917c85993adb6e84d37778bfc350ee9e342872e7f8be2"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1962e95067ac5ee784daddd573f828ef32f15e9c871967df6833d3d389113eae"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e924509dcac5a6054da357e3d6bcf37ea82984ee1d2a376569753d32f61ea8bb"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d8f463c370cdd71025b8d40f90e21e8fc25c7394eb64ebd53b1e566d712a3a68"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:7b94f00a0e69231ac4ebf0aa763734b9b5637e0ff13634ebfe6d13fadece71e9"}, + {file = "tree_sitter_zig-1.1.2-cp39-abi3-win_arm64.whl", hash = "sha256:88152ebeaeca1431a6fc943a8b391fee6f6a8058f17435015135157735061ddf"}, + {file = "tree_sitter_zig-1.1.2.tar.gz", hash = "sha256:da24db16df92f7fcfa34448e06a14b637b1ff985f7ce2ee19183c489e187a92e"}, +] + +[package.extras] +core = ["tree-sitter (>=0.22,<1.0)"] + [[package]] name = "urllib3" version = "2.6.3" @@ -666,4 +1520,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "4f90972e0662f4c917894e41299c18c1f35c669f90c1c94676e4d10d867f97d5" +content-hash = "8cdba271f6de64b1e0cdb827240b2aa129aa92df3fb11bb6a86b7b8bd954b1de" diff --git a/pyproject.toml b/pyproject.toml index bff928a..8b3a9d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textgenhub" -version = "1.2.2" +version = "2.0.0" description = "My personal LLM hub to connect to web-based LLMs" readme = "README.md" authors = ["Levente Csibi "] @@ -31,6 +31,7 @@ exclude = [ [tool.poetry.dependencies] python = ">=3.13" requests = "^2.31.0" +graphifyy = "^0.8.44" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..6f9f0f1 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,219 @@ +<# +.SYNOPSIS +    One-command setup for TextGenHub. +.DESCRIPTION +    Installs all dependencies (Python packages, Node.js packages, Ollama) +    and guides you through API key configuration. +    Run from PowerShell in the project root. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +function Write-Step($msg) { Write-Host "`n>>> $msg" -ForegroundColor Cyan } +function Write-Ok($msg) { Write-Host "  [OK] $msg" -ForegroundColor Green } +function Write-Skip($msg) { Write-Host "  [SKIP] $msg" -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host "  [FAIL] $msg" -ForegroundColor Red } +function Write-Info($msg) { Write-Host "      $msg" -ForegroundColor Gray } + +# ── Detect if running as admin (needed for Ollama installer) ── +function Test-Admin { +    $identity = [Security.Principal.WindowsIdentity]::GetCurrent() +    $principal = New-Object Security.Principal.WindowsPrincipal($identity) +    $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# ── 1. Check Python ── +Write-Step "Checking Python" +if (Get-Command python -ErrorAction SilentlyContinue) { +    $pyVersion = python --version 2>&1 +    Write-Ok "Python found: $pyVersion" +} else { +    Write-Err "Python not found." +    Write-Info "Download from https://www.python.org/downloads/" +    exit 1 +} + +# ── 2. Check Poetry ── +Write-Step "Checking Poetry" +if (Get-Command poetry -ErrorAction SilentlyContinue) { +    Write-Ok "Poetry found" +} else { +    Write-Info "Poetry not found. Installing..." +    powershell -Command "irm https://install.python-poetry.org | python -" +    # Add Poetry to PATH (current session) +    $poetryHome = "$env:USERPROFILE\.local\bin" +    if (-not $env:PATH.Split(';').Contains($poetryHome)) { +        $env:PATH = "$poetryHome;$env:PATH" +    } +    if (Get-Command poetry -ErrorAction SilentlyContinue) { +        Write-Ok "Poetry installed" +    } else { +        Write-Err "Poetry install failed. Run the command again." +        exit 1 +    } +} + +# ── 3. Check Node.js ── +Write-Step "Checking Node.js" +if (Get-Command node -ErrorAction SilentlyContinue) { +    $nodeVersion = node --version +    Write-Ok "Node.js found: $nodeVersion" +} else { +    Write-Err "Node.js not found." +    Write-Info "Required for web UI providers (ChatGPT, DeepSeek browser, etc.)" +    Write-Info "Download from https://nodejs.org/" +    $install = Read-Host "Install Node.js via winget? (y/N)" +    if ($install -eq "y") { +        Write-Info "Installing Node.js LTS (this may open a UAC prompt)..." +        winget install OpenJS.NodeJS.LTS --accept-source-agreement --accept-package-agreements +        Write-Ok "Node.js installed. Close and reopen this terminal, then run setup again." +        exit 0 +    } +    exit 1 +} + +# ── 4. Install Python dependencies ── +Write-Step "Installing Python dependencies" +poetry install +Write-Ok "Python dependencies installed" + +# ── 5. Install Node.js dependencies ── +Write-Step "Installing Node.js dependencies" +Push-Location src\textgenhub +npm install +Pop-Location +Write-Ok "Node.js dependencies installed" + +# ── 6. Install Ollama (optional) ── +Write-Step "Installing Ollama (optional, for local models)" +$ollama = Read-Host "Install Ollama? (y/N)" +if ($ollama -eq "y") { +    if (Get-Command ollama -ErrorAction SilentlyContinue) { +        $ollamaVersion = ollama --version +        Write-Ok "Ollama already installed: $ollamaVersion" +    } else { +        $isAdmin = Test-Admin +        if (-not $isAdmin) { +            Write-Info "Admin rights needed for Ollama installer. Relaunching as admin..." +            $scriptPath = $MyInvocation.MyCommand.Path +            $argsList = $args -join " " +            Start-Process powershell -Verb RunAs -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$scriptPath`"", $argsList +            exit 0 +        } +        Write-Info "Downloading Ollama installer..." +        $installer = "$env:TEMP\OllamaSetup.exe" +        Invoke-WebRequest -Uri "https://ollama.com/download/OllamaSetup.exe" -OutFile $installer -UseBasicParsing +        Write-Info "Installing Ollama (this may take a few minutes)..." +        Start-Process -FilePath $installer -ArgumentList "/S" -Wait +        Remove-Item $installer -Force -ErrorAction SilentlyContinue +        Write-Ok "Ollama installed" +    } + +    # Pull a default model +    $pull = Read-Host "Pull a default model (llama3)? (y/N)" +    if ($pull -eq "y") { +        Write-Info "Pulling llama3 (downloads ~4GB, first time only)..." +        ollama pull llama3 +        Write-Ok "Model installed" +    } +} else { +    Write-Skip "Skipping Ollama. You can install it later: https://ollama.com/" +} + +# ── 7. Set up API key ── +Write-Step "Setting up DeepSeek API key (optional)" +$apiKey = Read-Host "Enter your DeepSeek API key (or press Enter to skip)" +if ($apiKey -and $apiKey.Trim()) { +    [Environment]::SetEnvironmentVariable("DEEPSEEK_API_KEY", $apiKey.Trim(), "User") +    $env:DEEPSEEK_API_KEY = $apiKey.Trim() +    Write-Ok "API key saved (User-level environment variable)" +    Write-Info "It will be available in new terminals. To set it in your current session:" +    Write-Info '$env:DEEPSEEK_API_KEY = "' + $apiKey.Trim() + '"' +} else { +    Write-Skip "Skipping API key. You can set it later via:" +    Write-Info '$env:DEEPSEEK_API_KEY = "sk-..."' +} + +# ── 8. Developer tools (optional) ── +Write-Step "Developer tools (optional)" +$dev = Read-Host "Install dev tools (graphify, agent constraints, pre-commit hooks)? (y/N)" +if ($dev -eq "y") { +    # Graphify — code graph tool for agent-driven development +    if (Get-Command graphify -ErrorAction SilentlyContinue) { +        Write-Ok "Graphify already found" +    } else { +        Write-Info "Graphify is a compiled binary for generating code dependency graphs." +        Write-Info "Fetching latest release from GitHub..." + +        $githubApi = "https://api.github.com/repos/anthropics/graphify/releases/latest" +        try { +            $release = Invoke-RestMethod -Uri $githubApi -UseBasicParsing -ErrorAction Stop +            # Find the Windows x64 asset +            $asset = $release.assets | Where-Object { $_.name -match 'windows.*amd64' -or $_.name -match 'x86_64' } +            if (-not $asset) { +                Write-Skip "No Windows x64 release found. Available:" +                $release.assets | ForEach-Object { Write-Info "  $($_.name)" } +                $dl = Read-Host "  Paste download URL manually (or Enter to skip)" +                if ($dl -and $dl.Trim()) { $assetUrl = $dl.Trim() } else { $assetUrl = $null } +            } else { +                $assetUrl = $asset.browser_download_url +                Write-Ok "Found: $($asset.name)" +            } + +            if ($assetUrl) { +                $graphifyDest = "$env:USERPROFILE\.local\bin\graphify.exe" +                [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($graphifyDest)) | Out-Null +                Write-Info "Downloading..." +                Invoke-WebRequest -Uri $assetUrl -OutFile $graphifyDest -UseBasicParsing +                $graphifyBinDir = [System.IO.Path]::GetDirectoryName($graphifyDest) +                if (-not $env:PATH.Split(';').Contains($graphifyBinDir)) { +                    $env:PATH = "$graphifyBinDir;$env:PATH" +                } +                Write-Ok "Graphify installed to $graphifyDest" +            } else { +                Write-Skip "Skipping graphify" +            } +        } catch { +            Write-Err "Failed to fetch release: $_" +            Write-Info "Download manually from https://github.com/anthropics/graphify/releases" +        } +    } + +    # Generate initial graphify output +    if (Get-Command graphify -ErrorAction SilentlyContinue) { +        Write-Info "Generating initial code graph..." +        graphify update . 2>&1 | Out-Null +        if (Test-Path "graphify-out") { +            Write-Ok "Code graph generated (graphify-out/)" +        } +    } + +    # Pre-commit hooks +    if (Get-Command pre-commit -ErrorAction SilentlyContinue) { +        Write-Ok "pre-commit already installed" +    } else { +        $hooks = Read-Host "Install pre-commit hooks? (y/N)" +        if ($hooks -eq "y") { +            poetry run pre-commit install +            Write-Ok "Pre-commit hooks installed" +        } +    } +} else { +    Write-Skip "Skipping dev tools. You can install them later." +} + +# ── Done ── +Write-Host "`n========================================" -ForegroundColor White +Write-Host "  Setup complete!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor White +Write-Host "" +Write-Host "Try it out:" -ForegroundColor White +Write-Host "  poetry run textgenhub ollama --prompt 'hello'" -ForegroundColor Gray +Write-Host "  poetry run textgenhub deepseek-api --prompt 'hello'" -ForegroundColor Gray +Write-Host "" +Write-Host "Next steps:" -ForegroundColor White +Write-Host "  - Web UI providers (ChatGPT, etc.) require Chrome/Chromium" -ForegroundColor Gray +Write-Host "  - Download: https://www.google.com/chrome/" -ForegroundColor Gray +Write-Host "" diff --git a/src/textgenhub/api/__init__.py b/src/textgenhub/api/__init__.py new file mode 100644 index 0000000..754cfe4 --- /dev/null +++ b/src/textgenhub/api/__init__.py @@ -0,0 +1,7 @@ +""" +API-key-based proivders. +Each sub-module calls the provider's official REST API. +""" +from .deepseek import ask, DeepSeekAPI + +__all__ = ["deepseek", "ask", "DeepSeekAPI"] diff --git a/src/textgenhub/api/deepseek/__init__.py b/src/textgenhub/api/deepseek/__init__.py new file mode 100644 index 0000000..cc46adb --- /dev/null +++ b/src/textgenhub/api/deepseek/__init__.py @@ -0,0 +1,3 @@ +from .deepseek import ask, DeepSeekAPI + +__all__ = ["ask", "DeepSeekAPI"] diff --git a/src/textgenhub/api/deepseek/deepseek.py b/src/textgenhub/api/deepseek/deepseek.py new file mode 100644 index 0000000..aae8ecb --- /dev/null +++ b/src/textgenhub/api/deepseek/deepseek.py @@ -0,0 +1,117 @@ +""" +DeepSeek API provider - DeepSeek's official API. +""" +import os +import argparse +import requests + + +def ask( + prompt: str, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + timeout: int = 120, + system_prompt: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, +) -> str: + """Send a prompt to DeepSeek API and return the response.""" + api_key = api_key or os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + raise ValueError( + "DeepSeek API key is required." + "Set DEEPSEEK_API_KEY env var or pass api_key= parameter." + ) + + model = model or os.environ.get("DEEPSEEK_MODEL", "deepseek-chat") + base_url = base_url or os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.ai/v1") + + url = f"{base_url}/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + body = {"model": model, "messages": messages} + if temperature is not None: + body["temperature"] = temperature + if max_tokens is not None: + body["max_tokens"] = max_tokens + + try: + response = requests.post(url, headers=headers, json=body, timeout=timeout) + except requests.exceptions.ConnectionError as e: + raise RuntimeError(f"Failed to connect to DeepSeek API. Check your internet connection. Error message: {e}") + + status_code = response.status_code + if status_code == 401: + raise RuntimeError("DeepSeek API authentication failed. Check your API key.") + if status_code == 429: + raise RuntimeError("DeepSeek API rate limit exceeded. Try again later.") + if status_code != 200: + raise RuntimeError(f"DeepSeek API error {status_code}: {response.text}") + + return response.json()["choices"][0]["message"]["content"] + + +class DeepSeekAPI: + """DeepSeek API provider class.""" + + def __init__( + self, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + system_prompt: str | None = None, + ): + self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY") + self.model = model or os.environ.get("DEEPSEEK_MODEL", "deepseek-chat") + self.base_url = base_url or os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.ai/v1") + self.system_prompt = system_prompt + + def chat( + self, + prompt: str, + temperature: float | None = None, + max_tokens: int | None = None, + timeout: int = 120, + ) -> str: + "Send a chat prompt and return the response." + return ask( + prompt=prompt, + api_key=self.api_key, + model=self.model, + base_url=self.base_url, + timeout=timeout, + system_prompt=self.system_prompt, + temperature=temperature, + max_tokens=max_tokens, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="DeepSeek API provider CLI") + parser.add_argument("--prompt", required=True, help="Prompt to send") + parser.add_argument("--api_key", default=None, help="API key (default: $DEEPSEEK_API_KEY)") + parser.add_argument("--model", default=None, help="Model name (default: $DEEPSEEK_MODEL or deepseek-chat)") + parser.add_argument("--system-prompt", default=None, help="Optional system prompt") + parser.add_argument("--temperature", type=float, default=None, help="Temperature for response generation") + parser.add_argument("--max-tokens", type=int, default=None, help="Max output tokens") + args = parser.parse_args() + + response = ask( + prompt=args.prompt, + api_key=args.api_key, + model=args.model, + system_prompt=args.system_prompt, + temperature=args.temperature, + max_tokens=args.max_tokens, + ) + print(response) diff --git a/src/textgenhub/local/__init__.py b/src/textgenhub/local/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/textgenhub/local/ollama/__init__.py b/src/textgenhub/local/ollama/__init__.py new file mode 100644 index 0000000..4ebea1b --- /dev/null +++ b/src/textgenhub/local/ollama/__init__.py @@ -0,0 +1,3 @@ +from .ollama import ask, Ollama, list_models + +__all__ = ["ask", "Ollama", "list_models"] diff --git a/src/textgenhub/local/ollama/ollama.py b/src/textgenhub/local/ollama/ollama.py new file mode 100644 index 0000000..628f5c5 --- /dev/null +++ b/src/textgenhub/local/ollama/ollama.py @@ -0,0 +1,118 @@ +""" +Ollama provider - locally-hosted LLM via Ollama REST API. +""" +import os +import argparse +import requests + + +def ask( + prompt: str, + model: str | None = None, + host: str | None = None, + port: int | None = None, + timeout: int = 120, + system_prompt: str | None = None, +) -> str: + """Send a prompt to a local Ollama instance and return the response.""" + model = model or os.environ.get("OLLAMA_MODEL", "llama3") + host = host or os.environ.get("OLLAMA_HOST", "localhost") + port = port if port is not None else int(os.environ.get("OLLAMA_PORT", "11434")) + + url = f"http://{host}:{port}/api/chat" + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": model, + "messages": messages, + "stream": False, + } + + try: + response = requests.post(url, json=payload, timeout=timeout) + except requests.exceptions.ConnectionError as e: + raise RuntimeError( + f"Failed to connect to Ollama at {url}. " + "Is Ollama running? Start it with `ollama serve`." + ) from e + + if response.status_code != 200: + raise RuntimeError( + f"Ollama API error (HTTP {response.status_code}): {response.text}" + ) + + response_json = response.json() + return response_json["message"]["content"] + + +class Ollama: + """Ollama provider class.""" + + def __init__( + self, + model: str | None = None, + host: str | None = None, + port: int | None = None, + ): + self.model = model or os.environ.get("OLLAMA_MODEL", "llama3") + self.host = host or os.environ.get("OLLAMA_HOST", "localhost") + self.port = port if port is not None else int(os.environ.get("OLLAMA_PORT", "11434")) + + def chat( + self, + prompt: str, + system_prompt: str | None = None, + timeout: int = 120, + ) -> str: + """Send a chat prompt and return the response.""" + return ask( + prompt=prompt, + model=self.model, + host=self.host, + port=self.port, + timeout=timeout, + system_prompt=system_prompt, + ) + + +def list_models( + host: str | None = None, + port: int | None = None, +) -> list[str]: + """List available Ollama models. Returns empty list on failure.""" + host = host or os.environ.get("OLLAMA_HOST", "localhost") + port = port if port is not None else int(os.environ.get("OLLAMA_PORT", "11434")) + + url = f"http://{host}:{port}/api/tags" + + try: + response = requests.get(url, timeout=10) + if response.status_code != 200: + return [] + data = response.json() + return sorted(m["name"] for m in data.get("models", [])) + except Exception: + return [] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Ollama provider CLI") + parser.add_argument("--prompt", required=True, help="Prompt to send") + parser.add_argument("--model", default=None, help="Model name (default: $OLLAMA_MODEL or llama3)") + parser.add_argument("--host", default=None, help="Ollama host (default: $OLLAMA_HOST or localhost)") + parser.add_argument("--port", type=int, default=None, help="Ollama port (default: $OLLAMA_PORT or 11434)") + parser.add_argument("--system-prompt", default=None, help="Optional system prompt") + args = parser.parse_args() + + response = ask( + args.prompt, + model=args.model, + host=args.host, + port=args.port, + system_prompt=args.system_prompt, + ) + print(response) diff --git a/src/textgenhub/package.json b/src/textgenhub/package.json index e60d012..cc0a596 100644 --- a/src/textgenhub/package.json +++ b/src/textgenhub/package.json @@ -1,6 +1,6 @@ { "name": "textgenhub", - "version": "1.2.2", + "version": "2.0.0", "type": "module", "main": "base-provider.js", "scripts": { diff --git a/src/textgenhub/webui/__init__.py b/src/textgenhub/webui/__init__.py new file mode 100644 index 0000000..b5b7ed6 --- /dev/null +++ b/src/textgenhub/webui/__init__.py @@ -0,0 +1,22 @@ +""" +Web UI browser-automation providers. +Each sub-module automates the provider's website via Puppeteer. +""" +from . import chatgpt +from . import deepseek +from . import grok +from . import perplexity + +from .chatgpt import ChatGPT +from .deepseek import DeepSeek +from .perplexity import Perplexity + +__all__ = [ + "chatgpt", + "deepseek", + "grok", + "perplexity", + "ChatGPT", + "DeepSeek", + "Perplexity", +] diff --git a/src/textgenhub/webui/chatgpt/__init__.py b/src/textgenhub/webui/chatgpt/__init__.py new file mode 100644 index 0000000..c02ed6f --- /dev/null +++ b/src/textgenhub/webui/chatgpt/__init__.py @@ -0,0 +1,14 @@ +from .chatgpt import ask, close + + +class ChatGPT: + """ChatGPT provider class""" + + def chat(self, prompt: str) -> str: + return ask(prompt) + + def close(self, session: int | None = None) -> None: + return close(session) + + +__all__ = ["ask", "ChatGPT"] diff --git a/src/textgenhub/webui/chatgpt/chatgpt.py b/src/textgenhub/webui/chatgpt/chatgpt.py new file mode 100644 index 0000000..85b3c07 --- /dev/null +++ b/src/textgenhub/webui/chatgpt/chatgpt.py @@ -0,0 +1,58 @@ +""" +ChatGPT provider (web UI automation) - thin wrapper over chatgpt-session CLI +""" +from pathlib import Path +from ...core.provider import SimpleProvider + + +def ask( + prompt: str, + headless: bool = True, + remove_cache: bool = True, + debug: bool = False, + timeout: int = 120, + typing_speed: float | None = None, + session: int | None = None, + close: bool = False, + max_trials: int = 10, +) -> str: + """ + Send a prompt to ChatGPT and get a response using the new session-based module. + + Args: + prompt (str): The prompt to send to ChatGPT + headless (bool): Ignored by session-based module (kept for compatibility) + remove_cache (bool): Ignored by session-based module (kept for compatibility) + debug (bool): Whether to enable debug mode + timeout (int): Timeout in seconds for the operation + typing_speed (float | None): Typing speed in seconds per character (default: None for instant paste, > 0 for character-by-character typing) + session (int | None): Specific session index to reuse (when using the session-based CLI) + close (bool): Close the browser session after the request completes + max_trials (int): Maximum number of retries on rate limit (default: 10) + + Returns: + str: The response from ChatGPT + """ + provider = SimpleProvider("chatgpt", "chatgpt_cli.js", script_dir=Path(__file__).parent) + return provider.ask( + prompt, + headless=headless, + remove_cache=remove_cache, + debug=debug, + timeout=timeout, + typing_speed=typing_speed, + session=session, + close=close, + max_trials=max_trials, + ) + + +def close(session: int | None = None) -> None: + """ + Close the browser session for ChatGPT. + + Args: + session (int | None): Specific session index to close (default: last used) + """ + provider = SimpleProvider("chatgpt", "chatgpt_cli.js", script_dir=Path(__file__).parent) + provider.ask(None, session=session, close=True) diff --git a/src/textgenhub/webui/chatgpt/chatgpt_cli.js b/src/textgenhub/webui/chatgpt/chatgpt_cli.js new file mode 100644 index 0000000..7d151ca --- /dev/null +++ b/src/textgenhub/webui/chatgpt/chatgpt_cli.js @@ -0,0 +1,499 @@ +#!/usr/bin/env node +import { argv } from 'process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { connectToExistingChrome, launchControlledChromium, ensureLoggedIn, sendPrompt } from './lib/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function getSessionsFilePath() { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local'); + return path.join(localAppData, 'textgenhub', 'sessions.json'); + } + const home = process.env.HOME || '/tmp'; + return path.join(home, '.local', 'share', 'textgenhub', 'sessions.json'); +} + +function getDefaultUserDataDir() { + if (process.env.CHATGPT_PROFILE) { + return process.env.CHATGPT_PROFILE; + } + + if (process.platform === 'win32') { + return path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local', 'chromium-chatgpt-sessions'); + } + + return path.join(process.env.HOME || '/tmp', '.config', 'chromium-chatgpt-sessions'); +} + +function getCentralSessionsDir() { + if (process.platform === 'win32') { + return path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local', 'chromium-chatgpt-sessions'); + } + + return path.join(process.env.HOME || '/tmp', '.config', 'chromium-chatgpt-sessions'); +} + +function loadSessions() { + const sessionsPath = getSessionsFilePath(); + if (!fs.existsSync(sessionsPath)) { + // Migration logic: check for local sessions.json in current dir or package root + const localPaths = [ + path.join(process.cwd(), 'sessions.json'), + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'sessions.json') + ]; + + for (const localPath of localPaths) { + if (fs.existsSync(localPath)) { + try { + const sessionsDir = path.dirname(sessionsPath); + if (!fs.existsSync(sessionsDir)) { + fs.mkdirSync(sessionsDir, { recursive: true }); + } + fs.copyFileSync(localPath, sessionsPath); + // Rename local to avoid confusion + fs.renameSync(localPath, localPath + '.migrated'); + console.error(`[INFO] Migrated local sessions.json from ${localPath} to ${sessionsPath}`); + return JSON.parse(fs.readFileSync(sessionsPath, 'utf8')); + } catch (e) { + console.error(`[WARNING] Failed to migrate local sessions.json: ${e.message}`); + } + } + } + + const sessionsDir = path.dirname(sessionsPath); + if (!fs.existsSync(sessionsDir)) { + fs.mkdirSync(sessionsDir, { recursive: true }); + } + const now = new Date().toISOString(); + const bootstrap = { + sessions: [ + { + index: 0, + id: 'chatgpt-session-bootstrap', + debugPort: 9222, + userDataDir: getCentralSessionsDir(), + createdAt: now, + lastUsed: now, + loginStatus: 'unknown', + provider: 'chatgpt' + } + ], + default_session: 0, + metadata: { + created: now, + last_updated: now, + last_active_session_index: 0, + session_cursor: 0 + } + }; + fs.writeFileSync(sessionsPath, JSON.stringify(bootstrap, null, 2), 'utf-8'); + return bootstrap; + } + + return JSON.parse(fs.readFileSync(sessionsPath, 'utf-8')); +} + +function saveSessions(data) { + data.metadata = data.metadata || {}; + data.metadata.last_updated = new Date().toISOString(); + fs.writeFileSync(getSessionsFilePath(), JSON.stringify(data, null, 2), 'utf-8'); +} + +function getSessionByIndex(data, index) { + return (data.sessions || []).find((session) => session.index === index); +} + +function resolveSessionIndex(data, explicitIndex) { + const sessions = data.sessions || []; + if (!sessions.length) { + return null; + } + + if (typeof explicitIndex === 'number') { + return explicitIndex; + } + + if (data.metadata && typeof data.metadata.last_active_session_index === 'number') { + return data.metadata.last_active_session_index; + } + + if (typeof data.default_session === 'number') { + return data.default_session; + } + + return [...sessions].sort((a, b) => a.index - b.index)[0].index; +} + +function isChatGPTDomain(url) { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + return hostname === 'chatgpt.com' || hostname === 'chat.openai.com'; + } catch { + // Invalid URL, not a ChatGPT domain + return false; + } +} + +async function enforceSingleChatPage(browser, keepPage) { + const pages = await browser.pages(); + for (const p of pages) { + if (p === keepPage) { + continue; + } + const url = p.url(); + if (isChatGPTDomain(url)) { + try { + // eslint-disable-next-line no-await-in-loop + await p.close(); + } catch { + // ignore + } + } + } +} + +function extractConversationFromUrl(url) { + try { + const urlObj = new URL(url); + // Only extract from ChatGPT domains + if (!isChatGPTDomain(url)) { + return null; + } + const match = urlObj.pathname.match(/^\/c\/([a-zA-Z0-9\-]+)$/); + return match ? match[1] : null; + } catch { + return null; + } +} + +function usage() { + console.log('Usage: node bin/send-prompt-cli.js [--help|-h] --prompt "Your prompt here" [--json|--html|--format|-f json|html] [--raw|-r] [--debug|-d] [--timeout|-t seconds] [--typing-speed speed] [--session INDEX] [--close|-c]'); + console.log(''); + console.log('Options:'); + console.log(' --help, -h Show this help message'); + console.log(' --prompt TEXT The prompt to send to ChatGPT'); + console.log(' --json Output in JSON format with events (default)'); + console.log(' --html Output in HTML format with events'); + console.log(' --format, -f FMT Output format: json or html'); + console.log(' --raw, -r Output raw text without any formatting or events'); + console.log(' --debug, -d Enable debug output'); + console.log(' --timeout, -t SEC Timeout in seconds (default: 120)'); + console.log(' --max-trials TRIALS Maximum number of retries on rate limit (default: 10)'); + console.log(' --typing-speed SPEED Typing speed in seconds per character (default: null for instant paste, > 0 for character-by-character typing)'); + console.log(' --session INDEX Explicit session index to use (see: poetry run textgenhub sessions list)'); + console.log(' --close, -c Close browser session after completion (default: keep open)'); + console.log(''); + console.log('Output Formats:'); + console.log(' Default (no flags): JSON format with connection/response events'); + console.log(' --json: JSON format with connection/response events'); + console.log(' --html: HTML format with connection/response events'); + console.log(' --raw: Plain text output only (no events or formatting)'); + console.log(''); + console.log('Examples:'); + console.log(' node bin/send-prompt-cli.js --prompt "What is AI?"'); + console.log(' node bin/send-prompt-cli.js --prompt "Hello world" --html'); + console.log(' node bin/send-prompt-cli.js --raw --prompt "Complex question"'); + console.log(' node bin/send-prompt-cli.js --prompt "One-time query" --close'); + console.log(' node bin/send-prompt-cli.js --prompt "Quick test" --typing-speed 0.01'); + process.exit(2); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = { prompt: null, format: 'json', debug: false, timeout: 120, maxTrials: 10, raw: false, closeBrowser: false, typingSpeed: null, sessionIndex: null }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--help' || a === '-h') { + usage(); + } + if (a === '--prompt') { + out.prompt = args[i + 1]; + i++; + continue; + } + if (a === '--format' || a === '-f') { + out.format = args[i + 1]; + i++; + continue; + } + if (a === '--json') { + out.format = 'json'; + continue; + } + if (a === '--html') { + out.format = 'html'; + continue; + } + if (a === '--debug' || a === '-d') { + out.debug = true; + continue; + } + if (a === '--timeout' || a === '-t') { + out.timeout = parseInt(args[i + 1]); + i++; + continue; + } + if (a === '--max-trials') { + out.maxTrials = parseInt(args[i + 1]); + i++; + continue; + } + if (a === '--typing-speed') { + const speedValue = args[i + 1]; + if (speedValue === 'null' || speedValue === 'None' || speedValue === '') { + out.typingSpeed = null; + } else { + out.typingSpeed = parseFloat(speedValue); + } + i++; + continue; + } + if (a === '--raw' || a === '-r') { + out.raw = true; + continue; + } + if (a === '--close' || a === '-c') { + out.closeBrowser = true; + continue; + } + if (a === '--session') { + const parsedIndex = parseInt(args[i + 1], 10); + if (Number.isNaN(parsedIndex)) { + console.error('Invalid value for --session. Please provide a numeric index.'); + process.exit(2); + } + out.sessionIndex = parsedIndex; + i++; + continue; + } + // No positional prompts allowed - must use --prompt + console.error(`Unknown argument: ${a}`); + console.error('Use --prompt to specify the prompt text.'); + usage(); + } + return out; +} + +(async function main() { + const { prompt, format, debug, timeout, maxTrials, raw, closeBrowser, typingSpeed, sessionIndex } = parseArgs(); + if (!prompt && !closeBrowser) return usage(); + + // Validate format + if (!['json', 'html'].includes(format)) { + console.error(`Invalid format: ${format}. Must be 'json' or 'html'`); + process.exit(2); + } + + let browser, page, browserLaunched = false; + try { + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'connecting', timestamp: new Date().toISOString() })); + } + + const sessionsData = loadSessions(); + const targetSession = resolveSessionIndex(sessionsData, sessionIndex); + if (targetSession === null) { + console.error('No sessions found. Create one with: node src/textgenhub/chatgpt/init_session.js'); + process.exit(1); + } + + const selectedSession = getSessionByIndex(sessionsData, targetSession); + if (!selectedSession) { + console.error(`Session index ${targetSession} not found. Run: poetry run textgenhub sessions list`); + process.exit(1); + } + + const debugPort = selectedSession.debugPort || 9222; + const browserURL = `http://127.0.0.1:${debugPort}`; + const userDataDir = selectedSession.userDataDir || getDefaultUserDataDir(); + + try { + ({ browser, page } = await connectToExistingChrome({ browserURL })); + } catch (connectError) { + if (closeBrowser && !prompt) { + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'session_already_closed', message: 'No running session found to close', sessionIndex: targetSession })); + } + process.exit(0); + } + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'launching_chrome', timestamp: new Date().toISOString(), sessionIndex: targetSession })); + } else if (!raw) { + console.log(`Chrome session ${targetSession} not running, launching...`); + } + ({ browser, page } = await launchControlledChromium({ userDataDir, debugPort, headless: false })); + browserLaunched = true; + } + + if (closeBrowser && !prompt) { + await browser.close(); + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'session_closed', message: 'Browser session closed successfully', sessionIndex: targetSession })); + } else if (!raw) { + console.log(`Browser session ${targetSession} closed.`); + } + process.exit(0); + } + + try { + await enforceSingleChatPage(browser, page); + } catch { + // ignore + } + + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'connected', timestamp: new Date().toISOString() })); + } + + // Ensure browser window is visible and positioned at bottom + try { + await page.bringToFront(); + await page.setViewport({ width: 1200, height: 800 }); + + // Position window at bottom of screen + await page.evaluate(() => { + try { + const screen = window.screen; + const visibleHeight = 100; + const y = screen.height - visibleHeight; + const x = Math.max(0, (screen.width - 1200) / 2); + window.moveTo(x, y); + } catch (e) { + // Ignore positioning errors + } + }); + } catch (windowError) { + // Ignore window positioning errors + } + + // quick login check + try { + await ensureLoggedIn(page); + } catch (err) { + if (!raw && format === 'json') { + console.error(JSON.stringify({ event: 'login_required', message: err.message, timestamp: new Date().toISOString() })); + } else if (!raw) { + console.error(`Login required: ${err.message}`); + } + // Clean up browser connection on login error + if (browser && closeBrowser) { + try { + await browser.close(); + } catch { + // Ignore disconnect errors during cleanup + } + } + process.exit(3); + } + + // Restore last conversation if present AND we didn't just launch the browser. + // If we just launched the browser, we want a fresh start (new chat). + if (!browserLaunched && selectedSession.lastConversationUrl && typeof selectedSession.lastConversationUrl === 'string') { + try { + const currentUrl = page.url(); + if (!currentUrl.includes('/c/') || currentUrl !== selectedSession.lastConversationUrl) { + await page.goto(selectedSession.lastConversationUrl, { waitUntil: 'networkidle2' }); + } + } catch { + // Ignore navigation failures. + } + } else if (browserLaunched) { + // If we just launched, ensure we clear any stale conversation state from the session data + try { + const updated = loadSessions(); + const sessionToUpdate = getSessionByIndex(updated, targetSession); + if (sessionToUpdate) { + sessionToUpdate.lastConversationUrl = null; + sessionToUpdate.lastConversationId = null; + saveSessions(updated); + } + } catch { + // ignore + } + } + + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'prompt_sent', prompt, timestamp: new Date().toISOString() })); + } + const response = await sendPrompt(page, prompt, debug, timeout, (responseLength) => { + if (!raw && format === 'json') { + console.log(JSON.stringify({ event: 'response_waiting', responseLength, timestamp: new Date().toISOString() })); + } else if (!raw) { + console.log(`Waiting for response to complete... (${responseLength} chars)`); + } + }, typingSpeed, maxTrials); + + // Persist conversation URL and session usage. + try { + const updated = loadSessions(); + const sessionToUpdate = getSessionByIndex(updated, targetSession); + if (sessionToUpdate) { + sessionToUpdate.lastUsed = new Date().toISOString(); + sessionToUpdate.loginStatus = 'logged_in'; + + const url = page.url(); + const conversationId = extractConversationFromUrl(url); + if (closeBrowser) { + sessionToUpdate.lastConversationUrl = null; + sessionToUpdate.lastConversationId = null; + } else if (conversationId) { + sessionToUpdate.lastConversationUrl = url; + sessionToUpdate.lastConversationId = conversationId; + } + + updated.metadata = updated.metadata || {}; + updated.metadata.last_active_session_index = targetSession; + saveSessions(updated); + } + } catch { + // ignore persistence errors + } + + if (raw) { + // Raw output - just the response text + console.log(response); + } else if (format === 'json') { + console.log(JSON.stringify({ event: 'response_received', responseLength: response.length, response, timestamp: new Date().toISOString() })); + // Also output in SimpleProvider expected format + console.log(JSON.stringify({ response })); + } else { + // HTML format - both as formatted HTML and as JSON response + const htmlContent = `

${response.replace(/\n/g, '
')}

`; + console.log(htmlContent); + // Also output in SimpleProvider expected format for extraction + console.log(JSON.stringify({ response: htmlContent })); + } + + if (closeBrowser) { + await browser.close(); + } else { + if (!raw) { + console.log(JSON.stringify({ event: 'session_kept_open', message: 'Browser session remains open for future use', timestamp: new Date().toISOString() })); + } + } + process.exit(0); + } catch (error) { + if (!raw && format === 'json') { + console.error(JSON.stringify({ event: 'error', message: error.message, stack: error.stack })); + } else if (!raw) { + console.error(`Error: ${error.message}`); + } else { + // Raw mode - just output the error message + console.error(error.message); + } + // Clean up browser connection on error + if (browser && closeBrowser) { + try { + await browser.close(); + } catch { + // Ignore disconnect errors during cleanup + } + } + process.exit(1); + } +})(); diff --git a/src/textgenhub/webui/chatgpt/init_session.js b/src/textgenhub/webui/chatgpt/init_session.js new file mode 100644 index 0000000..9c866b3 --- /dev/null +++ b/src/textgenhub/webui/chatgpt/init_session.js @@ -0,0 +1,252 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import { fileURLToPath } from 'url'; + +import { connectToExistingChrome, ensureLoggedIn, launchControlledChromium } from './lib/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function getSessionsFilePath() { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local'); + return path.join(localAppData, 'textgenhub', 'sessions.json'); + } + const home = process.env.HOME || '/tmp'; + return path.join(home, '.local', 'share', 'textgenhub', 'sessions.json'); +} + +function getDefaultUserDataDir() { + if (process.env.CHATGPT_PROFILE) { + return process.env.CHATGPT_PROFILE; + } + + if (process.platform === 'win32') { + return path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local', 'chromium-chatgpt-sessions'); + } + + return path.join(process.env.HOME || '/tmp', '.config', 'chromium-chatgpt-sessions'); +} + +function getCentralSessionsDir() { + if (process.platform === 'win32') { + return path.join('C:\\Users', process.env.USERNAME || 'Default', 'AppData', 'Local', 'chromium-chatgpt-sessions'); + } + + return path.join(process.env.HOME || '/tmp', '.config', 'chromium-chatgpt-sessions'); +} + +function loadSessions() { + const sessionsPath = getSessionsFilePath(); + if (!fs.existsSync(sessionsPath)) { + // Migration logic: check for local sessions.json in current dir or package root + const localPaths = [ + path.join(process.cwd(), 'sessions.json'), + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'sessions.json') + ]; + + for (const localPath of localPaths) { + if (fs.existsSync(localPath)) { + try { + const sessionsDir = path.dirname(sessionsPath); + if (!fs.existsSync(sessionsDir)) { + fs.mkdirSync(sessionsDir, { recursive: true }); + } + fs.copyFileSync(localPath, sessionsPath); + // Rename local to avoid confusion + fs.renameSync(localPath, localPath + '.migrated'); + console.error(`[INFO] Migrated local sessions.json from ${localPath} to ${sessionsPath}`); + return JSON.parse(fs.readFileSync(sessionsPath, 'utf8')); + } catch (e) { + console.error(`[WARNING] Failed to migrate local sessions.json: ${e.message}`); + } + } + } + + const sessionsDir = path.dirname(sessionsPath); + if (!fs.existsSync(sessionsDir)) { + fs.mkdirSync(sessionsDir, { recursive: true }); + } + const now = new Date().toISOString(); + const bootstrap = { + sessions: [ + { + index: 0, + id: 'chatgpt-session-bootstrap', + debugPort: 9222, + userDataDir: getDefaultUserDataDir(), + createdAt: now, + lastUsed: now, + loginStatus: 'unknown', + provider: 'chatgpt' + } + ], + default_session: 0, + metadata: { + created: now, + last_updated: now, + last_active_session_index: 0, + session_cursor: 0 + } + }; + fs.writeFileSync(sessionsPath, JSON.stringify(bootstrap, null, 2), 'utf-8'); + return bootstrap; + } + + return JSON.parse(fs.readFileSync(sessionsPath, 'utf-8')); +} + +function saveSessions(data) { + data.metadata = data.metadata || {}; + data.metadata.last_updated = new Date().toISOString(); + fs.writeFileSync(getSessionsFilePath(), JSON.stringify(data, null, 2), 'utf-8'); +} + +async function isPortActive(port) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`, { method: 'GET' }); + return res.ok; + } catch { + return false; + } +} + +async function pickAvailablePort(startPort = 9222, maxTries = 200) { + for (let i = 0; i < maxTries; i++) { + const port = startPort + i; + // Treat port as available if debugging endpoint isn't responding. + // This is simple and works well for local usage. + // eslint-disable-next-line no-await-in-loop + const active = await isPortActive(port); + if (!active) { + return port; + } + } + throw new Error('Unable to find an available debug port'); +} + +function promptEnter(message) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, () => { + rl.close(); + resolve(); + }); + }); +} + +async function waitForLogin(page, seconds) { + const deadline = Date.now() + seconds * 1000; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + await ensureLoggedIn(page); + return; + } catch { + // keep waiting + } + + if (Date.now() > deadline) { + throw new Error(`Login not detected within ${seconds} seconds`); + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 3000)); + } +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = { index: null }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--index') { + out.index = parseInt(args[i + 1]); + i++; + continue; + } + } + return out; +} + +(async function main() { + const { index: requestedIndex } = parseArgs(); + const sessionsData = loadSessions(); + sessionsData.sessions = sessionsData.sessions || []; + + const usedIndexes = new Set(sessionsData.sessions.map((s) => s.index)); + let nextIndex; + + if (requestedIndex !== null) { + // If a specific index is requested, use it (will overwrite if exists) + nextIndex = requestedIndex; + // Remove existing session with this index if it exists + sessionsData.sessions = sessionsData.sessions.filter((s) => s.index !== requestedIndex); + } else { + // Auto-assign next available index + nextIndex = 0; + while (usedIndexes.has(nextIndex)) { + nextIndex++; + } + } + + const debugPort = await pickAvailablePort(9222); + const id = `chatgpt-session-${Date.now()}`; + const centralDir = getCentralSessionsDir(); + const userDataDir = path.join(centralDir, id); + + const now = new Date().toISOString(); + const newSession = { + index: nextIndex, + id, + debugPort, + userDataDir, + createdAt: now, + lastUsed: now, + loginStatus: 'unknown', + provider: 'chatgpt' + }; + + sessionsData.sessions.push(newSession); + sessionsData.default_session = sessionsData.default_session ?? 0; + sessionsData.metadata = sessionsData.metadata || { created: now, session_cursor: 0 }; + sessionsData.metadata.last_active_session_index = nextIndex; + sessionsData.metadata.session_cursor = (sessionsData.metadata.session_cursor || 0) + 1; + saveSessions(sessionsData); + + console.log(`[INFO] Created session index ${nextIndex}`); + console.log(`[INFO] userDataDir: ${userDataDir}`); + console.log(`[INFO] debugPort: ${debugPort}`); + + // Launch browser for login. + const { browser, page } = await launchControlledChromium({ userDataDir, debugPort, headless: false }); + + console.log('[INFO] A Chrome window should be open for this session.'); + console.log('[INFO] Please log in to ChatGPT in that window.'); + console.log('[INFO] This script will auto-detect login, or you can press Enter to force a re-check.'); + + // Give the user a chance to speed things up. + await promptEnter('Press Enter once you completed login (or just wait)... '); + + await waitForLogin(page, 15 * 60); + + // Update login status. + const updated = loadSessions(); + const session = updated.sessions.find((s) => s.index === nextIndex); + if (session) { + session.loginStatus = 'logged_in'; + session.lastUsed = new Date().toISOString(); + updated.metadata.last_active_session_index = nextIndex; + saveSessions(updated); + } + + console.log('[INFO] Login detected. Session is ready and will be kept open.'); + console.log('[INFO] You can now run: poetry run textgenhub chatgpt --prompt "..." --session ' + nextIndex); + + // Intentionally do not close the browser. + await browser.disconnect(); +})(); diff --git a/src/textgenhub/webui/deepseek/__init__.py b/src/textgenhub/webui/deepseek/__init__.py new file mode 100644 index 0000000..fb0d615 --- /dev/null +++ b/src/textgenhub/webui/deepseek/__init__.py @@ -0,0 +1,11 @@ +from .deepseek import ask + + +class DeepSeek: + """DeepSeek provider class""" + + def chat(self, prompt: str) -> str: + return ask(prompt) + + +__all__ = ["ask", "DeepSeek"] diff --git a/src/textgenhub/webui/deepseek/deepseek.js b/src/textgenhub/webui/deepseek/deepseek.js new file mode 100644 index 0000000..412331d --- /dev/null +++ b/src/textgenhub/webui/deepseek/deepseek.js @@ -0,0 +1,632 @@ +/** + * DeepSeek Provider - Browser automation for DeepSeek Chat web interface + * Uses Puppeteer to interact with DeepSeek Chat when API access is not available + */ + +'use strict'; + +import path from 'path'; +import BaseLLMProvider from '../../core/base-provider.js';class DeepSeekProvider extends BaseLLMProvider { + /** + * Save current HTML page as artifact for debugging + * @param {string} reason - Reason for saving artifact + */ + async saveHtmlArtifact(reason = 'manual') { + try { + if (this.browserManager && this.browserManager.page) { + const html = await this.browserManager.page.content(); + const fs = require('fs'); + const path = require('path'); + const artifactDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(artifactDir)) fs.mkdirSync(artifactDir, { recursive: true }); + const htmlPath = path.join(artifactDir, `deepseek_manual_${reason}_${Date.now()}.html`); + fs.writeFileSync(htmlPath, html, 'utf8'); + this.logger.error(`Saved manual HTML artifact: ${htmlPath}`); + return htmlPath; + } + } catch (err) { + this.logger.error('Failed to save manual HTML artifact', { error: err.message }); + } + return null; + } + constructor(config = {}) { + super('deepseek', config); + + this.browserManager = null; + this.isLoggedIn = false; + this.sessionTimeout = config.sessionTimeout || 3600000; // 1 hour + this.lastSessionCheck = 0; + + this.removeCache = config.removeCache !== undefined ? config.removeCache : true; + + // DeepSeek-specific selectors for UI interactions + this.selectors = { + textArea: 'textarea.ds-input, textarea[placeholder*="Type your message"], textarea[id*="deepseek-chat"][id*="input"]', + sendButton: 'button[type="submit"], button[aria-label*="Send"], button[title*="Send"]', + messageContainer: '[data-message], .message, .chat-message, div[class*="message"]', + responseText: '.ds-message-content, [data-message-content], .message-content, .chat-content, div[class*="content"]', + userMessage: '[data-role="user"], .user-message, div[class*="user"]', + assistantMessage: '[data-role="assistant"], .assistant-message, div[class*="assistant"]', + regenerateButton: 'button[aria-label*="Regenerate"], button[title*="Regenerate"], button[class*="regenerate"]', + stopButton: 'button[aria-label*="Stop"], button[title*="Stop"], button[class*="stop"]', + clearButton: 'button[aria-label*="New Chat"], button[title*="New Chat"], button[class*="new-chat"]', + streamingResponse: '[data-streaming], .typing-indicator, .streaming, div[class*="typing"]', + errorMessage: '[data-error], .error-message, .error, div[class*="error"]', + consentDialog: '.fc-dialog.fc-choice-dialog', + consentButton: '.fc-button.fc-cta-consent.fc-primary-button', + ...config.selectors, + }; + + this.urls = { + chat: 'https://chat-deep.ai/deepseek-chat/', + ...config.urls, + }; + + // DeepSeek-specific configuration + this.config.headless = config.headless !== undefined ? config.headless : true; + this.config.timeout = config.timeout || 60000; + this.config.sessionTimeout = config.sessionTimeout || 3600000; + this.config.userDataDir = + this.config.userDataDir || path.join(process.cwd(), 'temp', 'deepseek-session'); + } + + /** + * Initialize the DeepSeek provider + */ + async initialize() { + try { + this.logger.info('Initializing DeepSeek provider...'); // crucial + const browserConfig = { + headless: this.config.headless, + timeout: this.config.timeout, + userDataDir: this.config.userDataDir, + }; + this.browserManager = new BrowserManager(browserConfig); + await this.browserManager.initialize(); + + // Navigate to DeepSeek Chat + this.logger.info('Navigating to DeepSeek Chat...', { url: this.urls.chat }); // crucial + await this.browserManager.navigateToUrl(this.urls.chat); + if (this.config.debug) this.logger.info('DeepSeek Chat navigation completed'); + + // Handle consent popup if it appears + try { + if (this.config.debug) this.logger.info('Checking for consent popup...'); + const consentDialog = await this.browserManager.page.$(this.selectors.consentDialog); + + if (consentDialog) { + if (this.config.debug) this.logger.info('Consent popup found, clicking Consent button...'); + const consentButton = await this.browserManager.page.$(this.selectors.consentButton); + + if (consentButton) { + await consentButton.click(); + if (this.config.debug) this.logger.info('Consent button clicked successfully'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } else { + if (this.config.debug) this.logger.warn('Consent button not found in popup'); + } + } else { + if (this.config.debug) this.logger.info('No consent popup found'); + } + } catch (error) { + if (this.config.debug) this.logger.warn('Error handling consent popup:', error.message); + } + + // Check for text area to confirm we're on the chat page + try { + if (this.config.debug) this.logger.info('Waiting for DeepSeek Chat interface...'); + + let maxAttempts = 5; + let attempt = 0; + let success = false; + + while (attempt < maxAttempts && !success) { + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 10000, + visible: true + }); + success = true; + } catch (error) { + attempt++; + if (attempt === maxAttempts) throw error; + await new Promise(resolve => setTimeout(resolve, 2000)); + await this.browserManager.page.reload(); + } + } + + this.isLoggedIn = true; + if (this.config.debug) this.logger.info('DeepSeek Chat interface ready.'); + + } catch (e) { + this.logger.error('Failed to initialize DeepSeek Chat interface:', e); + throw new Error('Could not access DeepSeek Chat interface. Please check the website availability.'); + } + + this.isInitialized = true; + if (this.config.debug) this.logger.info('DeepSeek provider initialized successfully'); + } catch (error) { + throw await this.handleError(error, 'initialization'); + } + } + + /** + * Generate content using DeepSeek Chat + * @param {string} prompt - The prompt to send + * @param {Object} options - Generation options + * @returns {Promise} The generated response + */ + async generateContent(prompt, options = {}) { + if (!this.isInitialized) { + throw await this.handleError(new Error('DeepSeek provider not initialized. Call initialize() first.'), 'content generation'); + } + + const startTime = Date.now(); + try { + if (this.config.debug) this.logger.debug('Starting content generation', { + promptLength: prompt.length, + }); + + if (this.config.debug) this.logger.debug('Validating prompt...'); + this.validatePrompt(prompt); + if (this.config.debug) this.logger.debug('Prompt validated successfully'); + + if (this.config.debug) this.logger.info('Sending prompt to DeepSeek Chat', { + promptLength: prompt.length, + options, + }); + + // Clear any existing text and input the prompt + const typingSpeed = options.typingSpeed; + if (this.config.debug) this.logger.debug(`${typingSpeed === null || typingSpeed === 0 ? 'Pasting' : 'Typing'} prompt into text area`); + try { + const textArea = await this.browserManager.waitForElement(this.selectors.textArea); + await textArea.click({ clickCount: 3 }); // Select all existing text + await textArea.press('Backspace'); // Clear existing text + + if (typingSpeed === null || typingSpeed === 0) { + // Use paste by default for performance + await this.browserManager.page.evaluate((text) => { + const textArea = document.querySelector('textarea'); + if (textArea) { + textArea.value = text; + textArea.dispatchEvent(new Event('input', { bubbles: true })); + } + }, prompt); + } else { + // Use character-by-character typing + await textArea.type(prompt, { delay: typingSpeed * 1000 }); + } + if (this.config.debug) this.logger.debug(`Prompt ${typingSpeed === null || typingSpeed === 0 ? 'pasted' : 'typed'} successfully`); + } catch (error) { + this.logger.error('Failed to paste prompt text', { + error: error.message, + }); + throw new Error(`Cannot paste prompt: ${error.message}`); + } + + // Send the message + if (this.config.debug) this.logger.debug('Attempting to send message via send button'); + const sendButtonSelectors = [ + 'button.ds-send-btn', + 'button[type="submit"]', + 'button[aria-label*="Send"]', + 'button[title*="Send"]' + ]; + + let sendButtonFound = false; + for (const selector of sendButtonSelectors) { + try { + await this.browserManager.waitForElement(selector, { timeout: 3000 }); + await this.browserManager.clickElement(selector); + this.logger.debug('Send button clicked successfully', { selector }); + sendButtonFound = true; + break; + } catch (error) { + this.logger.debug('Send button selector failed, trying next', { + selector, + error: error.message, + }); + } + } + + if (!sendButtonFound) { + this.logger.warn('All send button selectors failed, trying Enter key fallback'); + try { + const textArea = await this.browserManager.waitForElement(this.selectors.textArea); + await textArea.focus(); + await new Promise(resolve => setTimeout(resolve, 500)); + await textArea.press('Enter'); + this.logger.debug('Message sent via Enter key'); + sendButtonFound = true; + } catch (error) { + this.logger.error('Enter key fallback also failed', { error: error.message }); + } + } + + if (!sendButtonFound) { + this.logger.error('All send methods failed - DeepSeek interface may have changed'); + throw new Error('Cannot send message - DeepSeek interface may have changed'); + } + + // Wait for response to appear + this.logger.debug('Waiting for response...'); + const response = await this.waitForResponse(options); + + const duration = Date.now() - startTime; + const validatedResponse = this.validateResponse(response); + return validatedResponse; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error('Content generation failed', { + duration, + error: error.message, + stack: error.stack?.split('\n').slice(0, 5).join('\n'), + }); + throw await this.handleError(error, 'content generation'); + } + } + + /** + * Wait for DeepSeek response to appear + * @param {Object} options - Wait options + */ + async waitForResponse(options = {}) { + const timeout = options.timeout || 60000; // 1 minute default + const startTime = Date.now(); + + this.logger.debug('Waiting for DeepSeek response...'); + + try { + // Count messages before sending to know when new response appears + const initialMessageCount = await this.getMessageCount(); + this.logger.debug('Initial message count:', initialMessageCount); + + // Wait for response to start appearing (new message should appear) + const newMessageTimeout = 30000; // 30 seconds to start responding + const messageStartTime = Date.now(); + + while (Date.now() - messageStartTime < newMessageTimeout) { + const currentMessageCount = await this.getMessageCount(); + if (currentMessageCount > initialMessageCount) { + this.logger.debug('New message detected, waiting for completion...'); + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Wait for typing animation to complete + await this.waitForTypingComplete(); + + // Additional wait after typing complete + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Try multiple extraction strategies + const extractionStrategies = [ + { + name: 'ds-message-content', + selector: '.ds-message-content' + }, + { + name: 'assistant-message-last', + selector: '[data-role="assistant"]:last-child .content, .assistant-message:last-child .content' + }, + { + name: 'message-content-last', + selector: '.message-content:last-child' + }, + { + name: 'chat-content-last', + selector: '.chat-content:last-child' + }, + { + name: 'response-content', + selector: 'div[class*="response"]:last-child, div[class*="assistant"]:last-child' + } + ]; + + let extractedResponse = null; + let usedStrategy = null; + + for (const strategy of extractionStrategies) { + try { + this.logger.debug(`Trying extraction strategy: ${strategy.name}`, { + selector: strategy.selector, + }); + + const elements = await this.browserManager.page.$$eval( + strategy.selector, + (elements) => + elements + .map((el) => ({ + text: el.textContent || el.innerText || '', + html: el.innerHTML?.substring(0, 200) || '', + tagName: el.tagName, + className: el.className, + })) + .filter((item) => item.text.trim().length > 10) + ); + + this.logger.debug(`Strategy ${strategy.name} results`, { + count: elements.length, + elements: elements.map((e) => ({ + textPreview: e.text.substring(0, 100), + tagName: e.tagName, + className: e.className, + })), + }); + + if (elements.length > 0) { + const lastElement = elements[elements.length - 1]; + + // Filter out unwanted content (Copy, Retry buttons, UI elements) + const text = this.cleanResponseText(lastElement.text); + + if (text && text.trim().length > 0) { + extractedResponse = text.trim(); + usedStrategy = strategy.name; + this.logger.debug('Successfully extracted response', { + strategy: strategy.name, + responseLength: extractedResponse.length, + }); + break; + } + } + } catch (error) { + this.logger.debug(`Strategy ${strategy.name} failed`, { + error: error.message, + }); + } + } + + if (!extractedResponse) { + throw new Error('No valid response text found with any extraction strategy'); + } + + const duration = Date.now() - startTime; + + this.logger.info('Response extracted successfully', { + responseLength: extractedResponse.length, + extractionTime: `${duration}ms`, + }); + + return extractedResponse; + } catch (error) { + throw new Error(`Failed to get response: ${error.message}`); + } + } + + /** + * Clean response text by removing Copy/Retry buttons and UI elements + * @param {string} text - Raw response text + * @returns {string} Cleaned response text + */ + cleanResponseText(text) { + if (!text) return ''; + + // Remove common UI elements and button text + const uiPatterns = [ + /\bCopy\b/gi, + /\bRetry\b/gi, + /\bTry again\b/gi, + /\bRegenerate\b/gi, + /\bEdit\b/gi, + /\bShare\b/gi, + /\bSave\b/gi, + /\bDownload\b/gi, + /Chat Settings/gi, + /Chat Options/gi, + /Clear Chat/gi, + /Export/gi, + /Font Size/gi, + /Small|Medium|Large/gi, + /Display/gi, + /Type your message/gi, + /Send a message/gi, + /Consent/gi, + /Manage options/gi, + /Learn more/gi, + /^\s*Copy\s*$/gm, + /^\s*Retry\s*$/gm, + /^\s*Try again\s*$/gm + ]; + + let cleanedText = text; + + // Apply filters + for (const pattern of uiPatterns) { + cleanedText = cleanedText.replace(pattern, ''); + } + + // Remove extra whitespace and normalize + cleanedText = cleanedText + .replace(/\n\s*\n\s*\n/g, '\n\n') // Remove excessive line breaks + .replace(/^\s+|\s+$/g, '') // Trim start/end + .replace(/\s+/g, ' ') // Normalize spaces + .trim(); + + return cleanedText; + } + + /** + * Get the current number of messages in the chat + */ + async getMessageCount() { + try { + const count = await this.browserManager.page.$$eval( + '.ds-message-content, [data-message], .message, .chat-message', + (elements) => elements.length + ); + return count; + } catch (error) { + this.logger.warn('Error getting message count', { error: error.message }); + return 0; + } + } + + /** + * Wait for typing animation to complete + */ + async waitForTypingComplete() { + const maxWait = 60000; // Max 60s + const pollInterval = 500; + const start = Date.now(); + + this.logger.debug('Waiting for typing animation to complete...'); + + while (Date.now() - start < maxWait) { + try { + const isTyping = await this.browserManager.page.evaluate(() => { + // Look for typing indicators + const typingIndicators = [ + '[data-streaming]', + '.typing-indicator', + '.streaming', + 'div[class*="typing"]', + '[aria-label*="typing"]' + ]; + + for (const selector of typingIndicators) { + if (document.querySelector(selector)) { + return true; + } + } + + // Check if send button is disabled + const sendButton = document.querySelector('button[type="submit"]'); + if (sendButton && sendButton.disabled) { + return true; + } + + return false; + }); + + if (!isTyping) { + this.logger.debug('Typing animation completed'); + return; + } + + this.logger.debug('Still generating response...'); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } catch (error) { + this.logger.warn('Error checking typing status', { + error: error.message, + }); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + this.logger.warn('Typing animation check timed out'); + } + + /** + * Validate prompt input + * @param {string} prompt - The prompt to validate + */ + validatePrompt(prompt) { + if (!prompt || typeof prompt !== 'string') { + throw new Error('Prompt must be a non-empty string'); + } + if (prompt.trim().length === 0) { + throw new Error('Prompt cannot be empty'); + } + if (prompt.length > 32000) { + throw new Error('Prompt is too long (max 32000 characters)'); + } + } + + /** + * Validate response + * @param {string} response - The response to validate + * @returns {string} Validated response + */ + validateResponse(response) { + if (!response || typeof response !== 'string') { + throw new Error('Invalid response received'); + } + if (response.trim().length === 0) { + throw new Error('Empty response received'); + } + return response.trim(); + } + + /** + * Handle errors in the DeepSeek provider + * Simply delegates to parent which saves HTML artifacts + */ + async handleError(error, operation) { + // Reset session state on any error for safety + this.isLoggedIn = false; + this.lastSessionCheck = 0; + + // Parent handleError saves HTML and returns wrapped error + return await super.handleError(error, operation); + } + + /** + * Check if an error is recoverable + * @param {Error} error - The error to check + * @returns {boolean} Whether the error is recoverable + */ + isRecoverableError(error) { + const recoverableErrors = [ + 'TimeoutError', + 'net::ERR_NETWORK_CHANGED', + 'net::ERR_INTERNET_DISCONNECTED', + 'net::ERR_CONNECTION_RESET', + 'Navigation timeout', + ]; + + return recoverableErrors.some(e => error.message.includes(e)); + } + + /** + * Clear the chat to start fresh + */ + async clearChat() { + try { + this.logger.debug('Attempting to clear chat...'); + + // Try multiple selectors for the clear/new chat button + const clearSelectors = [ + 'button[aria-label*="New Chat"]', + 'button[title*="New Chat"]', + 'button[class*="new-chat"]', + 'button[aria-label*="Clear"]', + 'button[title*="Clear"]', + 'button[class*="clear"]' + ]; + + for (const selector of clearSelectors) { + try { + await this.browserManager.waitForElement(selector, { timeout: 3000 }); + await this.browserManager.clickElement(selector); + this.logger.debug('Chat cleared successfully', { selector }); + await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for UI to update + return; + } catch (error) { + this.logger.debug('Clear selector failed, trying next', { selector }); + } + } + + this.logger.warn('Could not find clear chat button, chat may not be cleared'); + } catch (error) { + this.logger.error('Error clearing chat', { error: error.message }); + } + } + + /** + * Clean up resources + */ + async cleanup() { + try { + if (this.browserManager && this.browserManager.browser) { + await this.browserManager.browser.close(); + } + } catch (error) { + this.logger.error('Error during cleanup:', error); + } + } +} + +export default DeepSeekProvider; diff --git a/src/textgenhub/webui/deepseek/deepseek.py b/src/textgenhub/webui/deepseek/deepseek.py new file mode 100644 index 0000000..c5d7ef3 --- /dev/null +++ b/src/textgenhub/webui/deepseek/deepseek.py @@ -0,0 +1,38 @@ +""" +DeepSeek provider - Simple and clean implementation +""" +from pathlib import Path +from ...core.provider import SimpleProvider + + +def ask(prompt: str, headless: bool = True, remove_cache: bool = True, debug: bool = False, timeout: int = 120, typing_speed: float | None = None) -> str: + """ + Send a prompt to DeepSeek and get a response. + + Args: + prompt (str): The prompt to send to DeepSeek + headless (bool): Whether to run browser in headless mode + remove_cache (bool): Whether to remove browser cache + debug (bool): Whether to enable debug mode + timeout (int): Timeout in seconds for the operation + typing_speed (float | None): Typing speed in seconds per character (default: None for instant paste, > 0 for character-by-character typing) + + Returns: + str: The response from DeepSeek + """ + provider = SimpleProvider("deepseek", "deepseek_cli.js", script_dir=Path(__file__).parent) + return provider.ask(prompt, headless, remove_cache, debug, timeout, typing_speed) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="DeepSeek CLI via Node.js wrapper") + parser.add_argument("--prompt", type=str, required=True, help="Prompt to send to DeepSeek") + parser.add_argument("--headless", type=lambda x: x.lower() == "true", default=True, help="Run Node in headless mode") + parser.add_argument("--remove-cache", type=lambda x: x.lower() == "false", default=False, help="Remove cache on cleanup") + + args = parser.parse_args() + + resp = ask(args.prompt, headless=args.headless, remove_cache=args.remove_cache) + print("Response returned from ask method: ", resp) diff --git a/src/textgenhub/webui/deepseek/deepseek_cli.js b/src/textgenhub/webui/deepseek/deepseek_cli.js new file mode 100644 index 0000000..9e0cc80 --- /dev/null +++ b/src/textgenhub/webui/deepseek/deepseek_cli.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +'use strict'; + +import DeepSeekProvider from './deepseek.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +(async () => { + const argv = yargs(hideBin(process.argv)) + .option('prompt', { type: 'string', demandOption: true }) + .option('headless', { type: 'boolean', default: true }) + .option('remove-cache', { type: 'boolean', default: false }) + .option('debug', { type: 'boolean', default: false }) + .option('output-format', { type: 'string', choices: ['json', 'html'], default: 'json' }) + .option('typing-speed', { type: 'number', default: null }) + .argv; + + const provider = new DeepSeekProvider({ + headless: argv.headless, + removeCache: argv['remove-cache'], + debug: argv.debug + }); + + try { + await provider.initialize(); + const response = await provider.generateContent(argv.prompt, { typingSpeed: argv['typing-speed'] }); + + if (argv['output-format'] === 'html') { + // For HTML output, try to get HTML content if available + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(html || response); + } else { + // JSON output with metadata + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(JSON.stringify({ + response, + html + }, null, 2)); + } + + await provider.cleanup(); + } catch (err) { + // Always output a JSON error object for CI artifact capture + let artifactPath = err.artifactPath || (err.originalError && err.originalError.artifactPath); + if (!artifactPath && typeof provider.saveHtmlArtifact === 'function') { + artifactPath = await provider.saveHtmlArtifact('error'); + } + console.log(JSON.stringify({ + error: err.message || String(err), + stack: err.stack, + artifactPath + })); + process.exit(1); + } +})(); diff --git a/src/textgenhub/webui/grok/__init__.py b/src/textgenhub/webui/grok/__init__.py new file mode 100644 index 0000000..83beaaa --- /dev/null +++ b/src/textgenhub/webui/grok/__init__.py @@ -0,0 +1,3 @@ +from .grok import ask + +__all__ = ["ask"] diff --git a/src/textgenhub/webui/grok/grok.js b/src/textgenhub/webui/grok/grok.js new file mode 100644 index 0000000..dc9acc3 --- /dev/null +++ b/src/textgenhub/webui/grok/grok.js @@ -0,0 +1,1542 @@ +/** + * Grok Provider - Browser automation for Grok web interface + * Uses Puppeteer to interact with Grok when API access is not available + * + * IMPORTANT: This provider uses NON-HEADLESS mode (headless=false). + * + * Reason: Grok.com is a complex React SPA that doesn't properly render + * response content to the DOM when running in Puppeteer's headless mode. + * The assistant message element exists but remains empty, making extraction impossible. + * + * The browser window is automatically minimized via Chrome DevTools Protocol + * to prevent interference with laptop usability during CI/automated execution. + * This approach ensures responses are properly rendered while maintaining + * a non-intrusive automated testing experience. + */ + +'use strict'; + +import path from 'path'; +import fs from 'fs'; +import BaseLLMProvider from '../../core/base-provider.js';class GrokProvider extends BaseLLMProvider { + + /** + * Save current HTML page as artifact for debugging + * @param {string} reason - Reason for saving artifact + */ + async saveHtmlArtifact(reason = 'manual') { + try { + if (this.browserManager && this.browserManager.page) { + const html = await this.browserManager.page.content(); + const fs = require('fs'); + const path = require('path'); + const artifactDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(artifactDir)) fs.mkdirSync(artifactDir, { recursive: true }); + const htmlPath = path.join(artifactDir, `grok_manual_${reason}_${Date.now()}.html`); + fs.writeFileSync(htmlPath, html, 'utf8'); + this.logger.error(`Saved manual HTML artifact: ${htmlPath}`); + return htmlPath; + } + } catch (err) { + this.logger.error('Failed to save manual HTML artifact', { error: err.message }); + } + return null; + } + constructor(config = {}) { + super('grok', config); + + this.browserManager = null; + this.isLoggedIn = false; + this.sessionTimeout = config.sessionTimeout || 3600000; // 1 hour + this.lastSessionCheck = 0; + + // Force debug mode for investigation + this.config.debug = true; + + this.removeCache = config.removeCache !== undefined ? config.removeCache : true; + this.continuous = config.continuous !== undefined ? config.continuous : false; + + // Grok-specific selectors (may need updates as UI changes) + this.selectors = { + loginButton: '[data-testid="login-button"]', + emailInput: '#username', + passwordInput: '#password', + submitButton: 'button[type="submit"]', + textArea: + 'textarea, input[type="text"], [contenteditable="true"], [role="textbox"], [data-testid*="input"], [data-testid*="composer"], #prompt-textarea, textarea[placeholder*="Ask"], textarea[placeholder*="Message"], [data-testid="composer-text-input"]', + sendButton: + 'button[type="submit"], button[aria-label*="Send"], button[data-testid*="send"], [data-testid="send-button"], button[aria-label*="Submit"], button[class*="send"], svg[aria-label*="Send"]', + messageContainer: + '[data-testid*="conversation-turn"], [data-testid="conversation-turn"], .group, .flex.flex-col, div[class*="conversation"]', + responseText: + '[data-testid*="conversation-turn"] .markdown, [data-testid*="conversation-turn"] div[class*="markdown"], .prose, div[class*="prose"], .whitespace-pre-wrap', + regenerateButton: + '[data-testid="regenerate-button"], button[aria-label*="Regenerate"]', + ...config.selectors, + }; + + this.urls = { + login: 'https://grok.com/auth/login', + chat: 'https://grok.com/', + ...config.urls, + }; + + // Grok-specific configuration with configurable headless mode + this.config.headless = + config.headless !== undefined ? config.headless : false; // Default to non-headless for Grok + this.config.timeout = config.timeout || 60000; + this.config.sessionTimeout = config.sessionTimeout || 3600000; + this.config.userDataDir = + this.config.userDataDir || path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'Default'); + } + + /** + * Initialize the Grok provider + */ + async initialize() { + try { + this.logger.info('Initializing Grok provider...'); // crucial + this.logger.debug('Provider config:', this.config); + const browserConfig = { + headless: this.config.headless, + timeout: this.config.timeout, + userDataDir: this.config.userDataDir, + minimizeWindow: true, // Minimize window since not headless + debug: true // Force debug for browser manager + }; + this.browserManager = new BrowserManager(browserConfig); + await this.browserManager.initialize(); + + this.logger.info('Navigating to Grok...', { url: this.urls.chat }); + await this.browserManager.navigateToUrl(this.urls.chat); + this.logger.debug('Grok navigation completed'); + + // Don't check for text area here - let generateContent handle session validation + // This allows for proper login flow on CI runners without existing sessions + this.isLoggedIn = false; // Will be set to true during ensureSessionValid + this.isInitialized = true; + this.logger.info('Grok provider initialized successfully'); + } catch (error) { + this.logger.error('Provider initialization failed', { + error: error.message, + stack: error.stack, + originalError: error.originalError ? { + message: error.originalError.message, + stack: error.originalError.stack + } : undefined + }); + throw await this.handleError(error, 'initialization'); + } + } + + /** + * Generate content using Grok + * @param {string} prompt - The prompt to send + * @param {Object} options - Generation options + */ + async generateContent(prompt, options = {}) { + await this.applyRateLimit(); + const startTime = Date.now(); + try { + if (this.config.debug) this.logger.debug('Starting content generation', { + promptLength: prompt.length, + }); + if (this.config.debug) this.logger.debug('Validating prompt...'); + this.validatePrompt(prompt); + if (this.config.debug) this.logger.debug('Prompt validated successfully'); + if (this.config.debug) this.logger.debug('Ensuring session is valid...'); + await this.ensureSessionValid(); + if (this.config.debug) this.logger.debug('Session validation completed'); + if (this.config.debug) this.logger.info('Sending prompt to Grok', { + promptLength: prompt.length, + options, + }); + + const currentUrl = await this.browserManager.getCurrentUrl(); + if (this.config.debug) this.logger.debug('Current URL before navigation check', { currentUrl }); + if (!currentUrl.includes('grok.com')) { + if (this.config.debug) this.logger.debug('Navigating to chat URL', { url: this.urls.chat }); + await this.browserManager.navigateToUrl(this.urls.chat); + } + + // Handle any popups first (simplified approach) + try { + await this.browserManager.page.evaluate(() => { + // Click all visible close buttons + const closeButtons = Array.from(document.querySelectorAll( + 'button[aria-label*="Close"], button[aria-label*="Dismiss"], [class*="close"]' + )).filter(el => el.offsetParent !== null); + closeButtons.forEach(btn => btn.click()); + + // Remove any remaining popups/dialogs + const popups = Array.from(document.querySelectorAll( + 'div[role="dialog"], [class*="popup"], [class*="modal"], [class*="overlay"]' + )).filter(el => el.offsetParent !== null); + popups.forEach(popup => popup.parentNode?.removeChild(popup)); + }); + await this.browserManager.delay(500); + } catch (e) { + if (this.config.debug) this.logger.debug('Error handling popups', { error: e.message }); + } + + // Find and prepare input (simplified approach) + try { + const inputReady = await this.browserManager.page.evaluate(() => { + // Look for various input types + const possibleInputs = [ + // Contenteditable divs + ...Array.from(document.querySelectorAll('div[contenteditable="true"]')), + // Textareas + ...Array.from(document.querySelectorAll('textarea')), + // Text inputs + ...Array.from(document.querySelectorAll('input[type="text"]')), + ].filter(el => { + const isVisible = el.offsetParent !== null; + const notInPopup = !el.closest('div[role="dialog"]') && !el.closest('[class*="popup"]') && !el.closest('[class*="modal"]'); + const notEmail = !el.getAttribute('placeholder')?.toLowerCase().includes('email') && + !el.getAttribute('type')?.includes('email') && + !el.className?.toLowerCase().includes('email'); + const notHidden = !el.getAttribute('hidden') && el.style.display !== 'none' && el.style.visibility !== 'hidden'; + return isVisible && notInPopup && notEmail && notHidden; + }); + + if (possibleInputs.length > 0) { + // Prefer the first visible input + const input = possibleInputs[0]; + if (input.tagName.toLowerCase() === 'div' && input.getAttribute('contenteditable') === 'true') { + input.innerHTML = ''; + input.focus(); + } else { + input.value = ''; + input.focus(); + } + return true; + } + return false; + }); + + if (!inputReady) { + throw new Error('Could not find appropriate input field'); + } + + // Input the prompt using direct assignment or typing based on typingSpeed + const typingSpeed = options.typingSpeed; + if (this.config.debug) this.logger.debug(`${typingSpeed === null || typingSpeed === 0 ? 'Pasting' : 'Typing'} prompt`); + + if (typingSpeed === null || typingSpeed === 0) { + // Paste the prompt using direct value assignment for performance + await this.browserManager.page.evaluate((prompt) => { + const textarea = document.querySelector('textarea'); + if (textarea) { + textarea.value = prompt; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + const contenteditable = document.querySelector('[contenteditable="true"]'); + if (contenteditable) { + contenteditable.textContent = prompt; + contenteditable.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + }, prompt); + } else { + // Use keyboard typing + await this.browserManager.page.keyboard.type(prompt); + } + if (this.config.debug) this.logger.debug(`Prompt ${typingSpeed === null || typingSpeed === 0 ? 'pasted' : 'typed'} successfully`); + + // Submit the prompt - try Enter key first + await this.browserManager.page.keyboard.press('Enter'); + if (this.config.debug) this.logger.debug('Submitted with Enter key'); + + // Wait for response + const response = await this.waitForResponse(options); + + const duration = Date.now() - startTime; + const validatedResponse = this.validateResponse(response); + this.logRequest(prompt, validatedResponse, duration, options); + return validatedResponse; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error('Content generation failed', { + duration, + error: error.message, + stack: error.stack?.split('\n').slice(0, 5).join('\n'), + }); + throw await this.handleError(error, 'content generation'); + } + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error('Content generation failed', { + duration, + error: error.message, + stack: error.stack?.split('\n').slice(0, 5).join('\n'), + }); + throw await this.handleError(error, 'content generation'); + } + } + + /** + * Wait for Grok response to appear (simplified version) + * @param {Object} options - Wait options + */ + async waitForResponse(options = {}) { + const timeout = options.timeout || 120000; // Increased to 2 minutes for CI + const startTime = Date.now(); + + this.logger.debug('Waiting for Grok response...'); + + try { + // Simple approach: wait for any response text to appear + let response = ''; + const maxWaitTime = timeout; + const checkInterval = 2000; // Check every 2 seconds + + for (let elapsed = 0; elapsed < maxWaitTime; elapsed += checkInterval) { + try { + // Check if we have any response content - use Grok-specific selectors + const currentResponse = await this.browserManager.page.evaluate(() => { + // Look for Grok assistant response elements specifically + // Find all message bubbles and identify the last assistant message + const messageBubbles = Array.from(document.querySelectorAll('div.message-bubble')); + + // Filter for assistant messages (not user messages) + // Assistant messages typically don't contain the user's input text + const assistantMessages = messageBubbles.filter(bubble => { + const text = bubble.textContent?.trim() || ''; + const isVisible = bubble.offsetParent !== null; + const hasContent = text.length > 0; // Allow any content, even short responses + + // Skip if this looks like user input (contains the question we just asked) + // This is a heuristic - look for messages that don't start with simple questions + const looksLikeUserInput = text.startsWith('What is') || + text.startsWith('Explain') || + text.startsWith('How') || + text.startsWith('Translate') || + text.startsWith('Calculate') || + text.startsWith('Solve'); + + return isVisible && hasContent && !looksLikeUserInput && + !text.includes('window.__') && + !text.includes('document.'); + }); + + // Get the last assistant message (most recent response) + if (assistantMessages.length > 0) { + const lastMessage = assistantMessages[assistantMessages.length - 1]; + return lastMessage.textContent?.trim() || ''; + } + + // Fallback: look for response-content-markdown that doesn't contain user input + const markdownElements = Array.from(document.querySelectorAll('div.response-content-markdown')); + const assistantMarkdowns = markdownElements.filter(el => { + const text = el.textContent?.trim() || ''; + const isVisible = el.offsetParent !== null; + const hasContent = text.length > 0; // Allow any content + + // Skip user inputs + const looksLikeUserInput = text.startsWith('What is') || + text.startsWith('Explain') || + text.startsWith('How') || + text.startsWith('Translate') || + text.startsWith('Calculate') || + text.startsWith('Solve'); + + return isVisible && hasContent && !looksLikeUserInput; + }); + + if (assistantMarkdowns.length > 0) { + const lastMarkdown = assistantMarkdowns[assistantMarkdowns.length - 1]; + return lastMarkdown.textContent?.trim() || ''; + } + + // Additional fallback: look for any content in assistant message containers + const assistantContainers = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]')); + if (assistantContainers.length > 0) { + const lastContainer = assistantContainers[assistantContainers.length - 1]; + const text = lastContainer.textContent?.trim() || ''; + if (text.length > 0) { + return text; + } + } + + return ''; + }); + + if (currentResponse && currentResponse.trim().length > 0) { + response = currentResponse.trim(); + this.logger.debug('Response found', { length: response.length }); + break; + } + + // Check for error messages + const errorFound = await this.browserManager.page.evaluate(() => { + const errorSelectors = [ + '[class*="error"]', + '[class*="Error"]', + 'div[role="alert"]', + '.text-red-500', + '.text-red-600', + ]; + for (const selector of errorSelectors) { + const element = document.querySelector(selector); + if (element && element.textContent && element.textContent.trim()) { + return element.textContent.trim(); + } + } + return null; + }); + + if (errorFound) { + throw new Error(`Grok returned error: ${errorFound}`); + } + + await this.browserManager.delay(checkInterval); + } catch (error) { + if (error.message.includes('Grok returned error')) { + throw error; + } + // Continue polling on other errors + this.logger.debug('Error during response check, continuing to poll', { error: error.message }); + await this.browserManager.delay(checkInterval); + } + } + + if (!response) { + throw new Error(`No response received within ${timeout}ms timeout`); + } + + const duration = Date.now() - startTime; + this.logger.debug('Response received', { duration, responseLength: response.length }); + + return response; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error('Failed to get response', { duration, error: error.message }); + throw error; + } + } + + /** + * Wait for typing animation to complete + */ + async waitForTypingComplete() { + + let extractedResponse = null; + let usedStrategy = null; + + // Try multiple extraction strategies for Grok + const extractionStrategies = [ + { + name: 'grok-assistant-message-bubble', + selector: 'div.message-bubble:not([class*="bg-surface"]):not([class*="border"])', + }, + { + name: 'grok-response-markdown', + selector: 'div.response-content-markdown', + }, + { + name: 'grok-assistant-container', + selector: '[data-message-author-role="assistant"]', + }, + { + name: 'grok-message-bubble-any', + selector: 'div.message-bubble', + }, + { + name: 'grok-markdown-any', + selector: '.markdown, .prose', + }, + ]; + + for (const strategy of extractionStrategies) { + try { + this.logger.debug(`Trying extraction strategy: ${strategy.name}`, { + selector: strategy.selector, + }); + + const elements = await this.browserManager.page.$$eval( + strategy.selector, + (elements) => { + // For each element, try to get all available text content + return elements.map((el) => { + // Try different methods to extract text + let text = ''; + + if (el.tagName === 'DIV' && el.hasAttribute('data-message-author-role')) { + // For message containers, get all text including nested elements + text = el.innerText || el.textContent || ''; + } else { + // Prefer innerText (renders as visible), fall back to textContent + text = el.innerText || el.textContent || ''; + } + + return { + text: text, + html: el.innerHTML?.substring(0, 200) || '', + tagName: el.tagName, + className: el.className, + }; + }).filter((item) => item.text.trim().length > 0); // Allow any non-empty text + } + ); + + this.logger.debug(`Strategy ${strategy.name} results`, { + count: elements.length, + elements: elements.map((e) => ({ + textPreview: e.text.substring(0, 100), + tagName: e.tagName, + className: e.className, + })), + }); + + if (elements.length > 0) { + // Get the last element (most recent response) + const lastElement = elements[elements.length - 1]; + + // Filter out obviously wrong content (page scripts, etc.) + if ( + !lastElement.text.includes('window.__') && + !lastElement.text.includes('document.') && + !lastElement.text.includes('__oai_') && + lastElement.text.trim().length > 0 // Allow any non-empty text, including short answers like "4" + ) { + extractedResponse = lastElement.text.trim(); + usedStrategy = strategy.name; + this.logger.debug('Successfully extracted response', { + strategy: strategy.name, + responseLength: extractedResponse.length, + }); + break; + } else { + this.logger.debug('Filtered out invalid content', { + strategy: strategy.name, + contentPreview: lastElement.text.substring(0, 100), + reason: 'Contains page scripts or too short', + }); + } + } + } catch (error) { + this.logger.debug(`Strategy ${strategy.name} failed`, { + error: error.message, + }); + } + } + + if (!extractedResponse) { + // Last resort: try to get any text content from conversation turns + this.logger.warn( + 'All extraction strategies failed, trying last resort...' + ); + + const lastResortContent = await this.browserManager.page.evaluate( + () => { + const turns = document.querySelectorAll( + '[data-testid*="conversation-turn"]' + ); + if (turns.length >= 2) { + // Get the last turn (should be assistant response) + const lastTurn = turns[turns.length - 1]; + + // Try multiple ways to extract text + const methods = { + textContent: lastTurn.textContent || '', + innerText: lastTurn.innerText || '', + innerHTML: lastTurn.innerHTML.substring(0, 500) || '', + }; + + // Also try to find all divs with text content + const allDivs = Array.from(lastTurn.querySelectorAll('div, p, span')) + .map(el => (el.textContent || el.innerText || '').trim()) + .filter(text => text && text.length > 0 && !text.includes('window.__')); + + // Get all text nodes + const textNodes = []; + const walk = document.createTreeWalker( + lastTurn, + NodeFilter.SHOW_TEXT, + null + ); + let node; + while (node = walk.nextNode()) { + const text = node.textContent?.trim(); + if (text && text.length > 0) { + textNodes.push(text); + } + } + + const allText = (lastTurn.textContent || lastTurn.innerText || '').trim(); + + // Try to find the actual response by looking for patterns + let responseText = ''; + + // If we only have "Grok said:" but there are other divs, try those + if ((allText === 'Grok said:' || allText === 'Grok said') && allDivs.length > 0) { + // Skip any div that is just "ChatGPT said:" and get the next ones + const responseOnly = allDivs.filter(div => + !div.toLowerCase().includes('chatgpt') && + !div.toLowerCase().includes('assistant') && + !div.toLowerCase().includes('said:') + ); + if (responseOnly.length > 0) { + responseText = responseOnly[responseOnly.length - 1]; + } + } + + if (!responseText) { + responseText = allText; + } + + return { + fullText: allText, + textNodes: textNodes, + allDivs: allDivs, + responseText: responseText, + methods: methods, + }; + } + return null; + } + ); + + if (lastResortContent && lastResortContent.responseText) { + extractedResponse = lastResortContent.responseText; + usedStrategy = 'last-resort'; + this.logger.debug('Last resort extraction succeeded', { + strategy: 'last-resort', + responseLength: extractedResponse.length, + fullTextPreview: lastResortContent.fullText?.substring(0, 100), + allDivsCount: lastResortContent.allDivs?.length, + allDivsPreview: lastResortContent.allDivs?.slice(0, 5), + textNodesCount: lastResortContent.textNodes?.length, + responseText: lastResortContent.responseText?.substring(0, 100), + }); + } + } + + // If still no response, check if this is a headless rendering issue + if (!extractedResponse || extractedResponse === 'ChatGPT said:' || extractedResponse === 'ChatGPT said') { + this.logger.warn('HEADLESS MODE: No content found with standard extraction, trying page-wide search'); + + // Try to find the response anywhere on the page + const pageWideSearch = await this.browserManager.page.evaluate(() => { + // Get all text from the page except common UI elements + const bodyText = document.body.innerText || document.body.textContent || ''; + + // Find lines that look like responses (not UI text) + const lines = bodyText.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + // Filter out common UI elements + const uiKeywords = ['send', 'message', 'new chat', 'settings', 'upgrade', 'edit', 'copy', 'delete', 'regenerate', 'continue']; + const responseLines = lines.filter(line => { + const lower = line.toLowerCase(); + return !uiKeywords.some(kw => lower === kw || lower.startsWith(kw + ' ')); + }); + + return responseLines; + }); + + if (pageWideSearch && pageWideSearch.length > 0) { + // Find the most likely response (usually one of the last meaningful lines) + const candidateResponse = pageWideSearch[pageWideSearch.length - 1]; + if (candidateResponse && candidateResponse.length > 0 && candidateResponse !== 'ChatGPT said:') { + extractedResponse = candidateResponse; + usedStrategy = 'page-wide-search-headless'; + this.logger.debug('Found response via page-wide search', { + response: extractedResponse.substring(0, 100), + totalLines: pageWideSearch.length + }); + } + } + } + + if (!extractedResponse) { + throw new Error( + 'No valid response text found with any extraction strategy' + ); + } + + // Check if response indicates still generating - wait longer + if (extractedResponse && ( + extractedResponse.toLowerCase().includes('still generating') || + extractedResponse.toLowerCase().includes('generating a response') || + extractedResponse.toLowerCase().includes('chatgpt is still') + )) { + this.logger.warn('Response indicates still generating, waiting 5 seconds and retrying extraction...', { + currentResponse: extractedResponse.substring(0, 100) + }); + await this.browserManager.delay(5000); + + // Retry extraction with the same strategies + extractedResponse = null; + usedStrategy = null; + + for (const strategy of extractionStrategies) { + try { + this.logger.debug(`Retrying extraction strategy: ${strategy.name}`, { + selector: strategy.selector, + }); + + const elements = await this.browserManager.page.$$eval( + strategy.selector, + (elements) => { + return elements.map((el) => { + let text = ''; + + if (el.tagName === 'DIV' && el.hasAttribute('data-message-author-role')) { + text = el.innerText || el.textContent || ''; + } else { + text = el.innerText || el.textContent || ''; + } + + return { + text: text, + html: el.innerHTML?.substring(0, 200) || '', + tagName: el.tagName, + className: el.className, + }; + }).filter((item) => item.text.trim().length > 0); + } + ); + + if (elements.length > 0) { + const lastElement = elements[elements.length - 1]; + + if ( + !lastElement.text.includes('window.__') && + !lastElement.text.includes('document.') && + !lastElement.text.includes('__oai_') && + lastElement.text.trim().length > 0 + ) { + extractedResponse = lastElement.text.trim(); + usedStrategy = strategy.name + '-retry'; + this.logger.debug('Successfully extracted response on retry', { + strategy: strategy.name + '-retry', + responseLength: extractedResponse.length, + }); + break; + } + } + } catch (error) { + this.logger.debug(`Retry strategy ${strategy.name} failed`, { + error: error.message, + }); + } + } + + // If still no response or still generating, try page-wide search again + if (!extractedResponse || extractedResponse.toLowerCase().includes('still generating') || extractedResponse.toLowerCase().includes('generating a response')) { + this.logger.warn('Retry extraction still indicates generating, trying page-wide search again'); + const pageWideSearch = await this.browserManager.page.evaluate(() => { + const bodyText = document.body.innerText || document.body.textContent || ''; + const lines = bodyText.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + const uiKeywords = ['send', 'message', 'new chat', 'settings', 'upgrade', 'edit', 'copy', 'delete', 'regenerate', 'continue']; + const responseLines = lines.filter(line => { + const lower = line.toLowerCase(); + return !uiKeywords.some(kw => lower === kw || lower.startsWith(kw + ' ')); + }); + + return responseLines; + }); + + if (pageWideSearch && pageWideSearch.length > 0) { + const candidateResponse = pageWideSearch[pageWideSearch.length - 1]; + if (candidateResponse && candidateResponse.length > 0 && candidateResponse !== 'ChatGPT said:' && + !candidateResponse.toLowerCase().includes('still generating') && + !candidateResponse.toLowerCase().includes('generating a response')) { + extractedResponse = candidateResponse; + usedStrategy = 'page-wide-search-retry'; + this.logger.debug('Found response via page-wide search on retry', { + response: extractedResponse.substring(0, 100), + totalLines: pageWideSearch.length + }); + } + } + } + + if (!extractedResponse) { + throw new Error('No valid response found even after retry'); + } + } + + const duration = Date.now() - startTime; + } + + /** + * Wait for typing animation to complete + */ + async waitForTypingComplete() { + const maxWait = 90000; // Increased to 90s for CI environments + const pollInterval = 500; // Check every 500ms + const start = Date.now(); + + this.logger.debug('Waiting for typing animation to complete...'); + + // First wait a minimum time for response to start + await this.browserManager.delay(2000); + + let previousContentLength = 0; + let stableCount = 0; + const stableThreshold = 3; // Need 3 consecutive checks with same content length + + while (Date.now() - start < maxWait) { + try { + const status = await this.browserManager.page.evaluate(() => { + // Check for stop button (appears during generation) + const stopButton = document.querySelector('[data-testid="stop-button"]') || + document.querySelector('button[aria-label*="Stop"]'); + + // Check if send button is disabled + const sendButton = document.querySelector('[data-testid="send-button"]') || + document.querySelector('button[data-testid="send-button"]'); + + // Check the ARIA live region for generation status (most reliable in headless) + const liveRegion = document.querySelector('[aria-live="assertive"]'); + const liveRegionText = liveRegion ? (liveRegion.textContent || '').toLowerCase() : ''; + const isStillGenerating = liveRegionText.includes('generating') || liveRegionText.includes('still'); + + // Check for substantial content in the last assistant message + const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]'); + const lastMessage = assistantMessages[assistantMessages.length - 1]; + const content = lastMessage ? (lastMessage.textContent || lastMessage.innerText || '') : ''; + const contentLength = content.trim().length; + const hasSubstantialContent = contentLength > 10; // Lower threshold + + // Most important: check the live region first - if it says "still generating", we're not done + if (isStillGenerating) { + return { status: 'typing', contentLength, reason: 'live-region-says-generating' }; + } + + // If stop button is gone AND we have some content, we're done + if (!stopButton && hasSubstantialContent) { + return { status: 'complete', contentLength, reason: 'no-stop-button-has-content' }; + } + + // If stop button exists or send button is disabled, still generating + if (stopButton || (sendButton && sendButton.disabled)) { + return { status: 'typing', contentLength, reason: 'stop-button-exists' }; + } + + // If we have substantial content but no stop button, we're done + if (hasSubstantialContent) { + return { status: 'complete', contentLength, reason: 'has-substantial-content' }; + } + + return { status: 'waiting', contentLength, reason: 'no-content-yet' }; // No substantial content yet + }); + + this.logger.debug('Typing status check', { status: status.status, contentLength: status.contentLength, reason: status.reason }); + + // Check if content has stabilized (stopped changing) + if (status.contentLength === previousContentLength && status.contentLength > 0) { + stableCount++; + this.logger.debug('Content stable check', { stableCount, contentLength: status.contentLength }); + + // If content hasn't changed for a few checks, consider it done + if (stableCount >= stableThreshold) { + this.logger.debug('Content has stabilized - marking as complete', { + contentLength: status.contentLength, + stableCount + }); + return; + } + } else { + stableCount = 0; // Reset if content changed + previousContentLength = status.contentLength; + } + + if (status.status === 'complete') { + this.logger.debug('Typing animation completed', { contentLength: status.contentLength, reason: status.reason }); + return; + } else if (status.status === 'typing') { + this.logger.debug('Still generating response...', { contentLength: status.contentLength, reason: status.reason }); + } else { + this.logger.debug('Waiting for response content...', { contentLength: status.contentLength }); + } + + await this.browserManager.delay(pollInterval); + } catch (error) { + this.logger.warn('Error checking typing status', { + error: error.message, + }); + await this.browserManager.delay(pollInterval); + } + } + + this.logger.warn('Typing animation check timed out - proceeding with extraction'); + } + + /** + * Wait for assistant message content to be populated (critical for headless mode) + */ + async waitForAssistantContent() { + const maxWait = 90000; // 90 seconds for headless mode + const pollInterval = 500; // Check every 500ms + const start = Date.now(); + + this.logger.debug('Waiting for assistant message content to be populated...'); + + while (Date.now() - start < maxWait) { + try { + const contentStatus = await this.browserManager.page.evaluate(() => { + const assistantMessages = document.querySelectorAll('[data-message-author-role="assistant"]'); + if (assistantMessages.length === 0) { + return { hasAssistantMessage: false, contentLength: 0 }; + } + + const lastMessage = assistantMessages[assistantMessages.length - 1]; + + // Check for React-rendered content in data attributes + const reactContent = lastMessage.querySelector('[data-start], [data-end]'); + if (reactContent) { + const reactText = reactContent.textContent || reactContent.innerText || ''; + if (reactText.trim().length > 0) { + return { + hasAssistantMessage: true, + contentLength: reactText.trim().length, + content: reactText.trim().substring(0, 100), + hasActualContent: true, + source: 'react-data' + }; + } + } + + // Check for markdown/prose content + const markdownContent = lastMessage.querySelector('.markdown, .prose, p'); + if (markdownContent) { + const markdownText = markdownContent.textContent || markdownContent.innerText || ''; + if (markdownText.trim().length > 0 && markdownText.trim() !== 'ChatGPT said:') { + return { + hasAssistantMessage: true, + contentLength: markdownText.trim().length, + content: markdownText.trim().substring(0, 100), + hasActualContent: true, + source: 'markdown' + }; + } + } + + // Check all text in the message container + const allText = lastMessage.textContent || lastMessage.innerText || ''; + const textContent = allText.trim(); + + // Filter out "ChatGPT said:" which is just UI text + if (textContent && textContent !== 'ChatGPT said:' && textContent !== 'ChatGPT said') { + return { + hasAssistantMessage: true, + contentLength: textContent.length, + content: textContent.substring(0, 100), + hasActualContent: true, + source: 'textContent' + }; + } + + return { + hasAssistantMessage: true, + contentLength: textContent.length, + content: textContent.substring(0, 100), + hasActualContent: false, + source: 'textContent' + }; + }); + + if (contentStatus.hasAssistantMessage && contentStatus.hasActualContent) { + this.logger.debug('Assistant message content populated', { + contentLength: contentStatus.contentLength, + contentPreview: contentStatus.content, + source: contentStatus.source + }); + return; + } + + this.logger.debug('Waiting for assistant content...', { + hasAssistantMessage: contentStatus.hasAssistantMessage, + contentLength: contentStatus.contentLength, + contentPreview: contentStatus.content + }); + + await this.browserManager.delay(pollInterval); + } catch (error) { + this.logger.warn('Error checking assistant content', { + error: error.message, + }); + await this.browserManager.delay(pollInterval); + } + } + + this.logger.warn('Assistant content wait timed out - proceeding with extraction anyway'); + } + + /** + * Ensure user is logged in to ChatGPT + */ + async ensureLoggedIn() { + if (this.isLoggedIn && this.isSessionValid()) { + return; + } + + this.logger.info('Checking Grok login status...'); + + try { + // Navigate to chat page first + await this.browserManager.navigateToUrl(this.urls.chat); + + // Check if we're already logged in by looking for the text area + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 5000, + }); + this.isLoggedIn = true; + this.lastSessionCheck = Date.now(); + this.logger.info('Already logged in to Grok'); + return; + } catch (error) { + this.logger.info('Not logged in, need to authenticate'); + } + + // If we have credentials, attempt automatic login + if (this.config.email && this.config.password) { + await this.performLogin(); + } else { + // Manual login required + this.logger.warn('No credentials provided. Manual login required.'); + this.logger.info('Please login manually and then continue...'); + + // Wait for manual login (detect when text area appears) + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 300000, // 5 minutes for manual login + }); + + this.isLoggedIn = true; + this.lastSessionCheck = Date.now(); + this.logger.info('Manual login completed'); + } + } catch (error) { + throw await this.handleError(error, 'login process'); + } + } + + /** + * Perform automatic login + */ + async performLogin() { + this.logger.info('Attempting automatic login...'); + + try { + // Navigate to login page + await this.browserManager.navigateToUrl(this.urls.login); + + // Wait for and fill email + await this.browserManager.waitForElement(this.selectors.emailInput); + await this.browserManager.typeText( + this.selectors.emailInput, + this.config.email + ); + + // Continue to password + await this.browserManager.clickElement(this.selectors.submitButton); + + // Wait for and fill password + await this.browserManager.waitForElement(this.selectors.passwordInput); + await this.browserManager.typeText( + this.selectors.passwordInput, + this.config.password + ); + + // Submit login + await this.browserManager.clickElement(this.selectors.submitButton); + + // Wait for successful login (text area appears) + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 30000, + }); + + this.isLoggedIn = true; + this.lastSessionCheck = Date.now(); + this.logger.info('Automatic login successful'); + } catch (error) { + throw new Error(`Login failed: ${error.message}`); + } + } + + /** + * Check if current session is still valid + */ + isSessionValid() { + const now = Date.now(); + return now - this.lastSessionCheck < this.sessionTimeout; + } + + /** + * Ensure session is valid, refresh if needed + */ + async ensureSessionValid() { + // First check time-based expiration + if (!this.isSessionValid()) { + this.logger.info('Session expired (time-based), refreshing...'); + this.isLoggedIn = false; + await this.ensureLoggedIn(); + return; + } + + // Also check if text area is still accessible (element-based validation) + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 3000, + }); + this.logger.debug('Session valid - text area accessible'); + // Update last check time since element is available + this.lastSessionCheck = Date.now(); + } catch (error) { + this.logger.warn('Session invalid - text area not found, refreshing session', { + error: error.message, + }); + this.isLoggedIn = false; + // Try to recover by navigating back to chat page + await this.browserManager.navigateToUrl(this.urls.chat); + + // Wait for text area to appear after navigation + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 10000, + }); + this.isLoggedIn = true; + this.lastSessionCheck = Date.now(); + this.logger.info('Session recovered after navigation'); + } catch (recoveryError) { + this.logger.error('Session recovery failed, need full re-login', { + error: recoveryError.message, + }); + await this.ensureLoggedIn(); + } + } + } + + /** + * Reset browser state by navigating to a fresh chat page + */ + async resetBrowserState() { + try { + this.logger.debug('Resetting browser state...'); + // Navigate directly to home to get a fresh chat + await this.browserManager.navigateToUrl('https://grok.com/'); + await this.browserManager.delay(2000); + + // Wait for the text area to be available + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 10000, + }); + this.logger.debug('Browser state reset complete'); + } catch (error) { + this.logger.warn('Failed to reset browser state', { + error: error.message, + }); + } + } + + /** + * Start a new chat to ensure clean conversation state + */ + async startNewChat() { + try { + // Try multiple selectors for the "New chat" button + const newChatSelectors = [ + '[data-testid="new-chat-button"]', + 'button[data-testid="new-chat-button"]', + 'a[href="/"]', + 'button:has(svg):first-child', // Often the first button with an icon + '[aria-label*="New chat"]', + 'button[aria-label*="New chat"]' + ]; + + let newChatClicked = false; + for (const selector of newChatSelectors) { + try { + await this.browserManager.waitForElement(selector, { timeout: 3000 }); + await this.browserManager.clickElement(selector); + this.logger.debug('New chat button clicked', { selector }); + newChatClicked = true; + + // Wait for the page to reset and text area to appear + await this.browserManager.waitForElement(this.selectors.textArea, { timeout: 10000 }); + this.logger.debug('New chat started successfully'); + break; + } catch (error) { + this.logger.debug('New chat selector failed, trying next', { + selector, + error: error.message, + }); + } + } + + if (!newChatClicked) { + this.logger.debug('Could not find new chat button, assuming we are already in a fresh state'); + } + } catch (error) { + this.logger.warn('Failed to start new chat, continuing with current state', { + error: error.message, + }); + } + } + + /** + * Check if ChatGPT is healthy and responsive + */ + async isHealthy() { + try { + if (!this.browserManager || !(await this.browserManager.isHealthy())) { + this.logger.debug('Browser manager not healthy'); + return false; + } + + // If we're not initialized yet, we can't be healthy + if (!this.isInitialized) { + this.logger.debug('Provider not initialized'); + return false; + } + + // Check if we can access the chat interface + const currentUrl = await this.browserManager.getCurrentUrl(); + if (!currentUrl.includes('chatgpt.com')) { + this.logger.debug('Not on ChatGPT page', { currentUrl }); + return false; + } + + // If we're logged in and session is valid, we're healthy + if (this.isLoggedIn && this.isSessionValid()) { + this.logger.debug('Logged in with valid session'); + return true; + } + + // Try to check if text area is available (indicates we're logged in) + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 2000, // Short timeout for health check + }); + + // Update login status if we found the text area + this.isLoggedIn = true; + this.lastSessionCheck = Date.now(); + this.logger.debug('Text area found, updating login status'); + return true; + } catch (error) { + this.logger.debug('Text area not found', { error: error.message }); + return false; + } + } catch (error) { + this.logger.error('Health check failed', { error: error.message }); + return false; + } + } + + /** + * Clean up resources + */ + async cleanup() { + await super.cleanup(); + + // if (this.browserManager) { + // if (this.removeCache) await this.browserManager.cleanupCache(); + // await this.browserManager.close(); + // this.browserManager = null; + // } + + if (this.browserManager) { + await this.browserManager.close(); + if (this.removeCache) await this.browserManager.cleanupCache(); + this.browserManager = null; + } + + this.isLoggedIn = false; + this.lastSessionCheck = 0; +} + + /** + * Check for and handle "Continue manually" or similar authentication prompts + */ + async handleContinueManuallyPrompt(maxWaitMs = 5000) { + try { + const pollInterval = 500; + const maxTries = Math.ceil(maxWaitMs / pollInterval); + + for (let attempt = 0; attempt < maxTries; attempt++) { + // Check for various popup types + const popupInfo = await this.browserManager.page.evaluate(() => { + const popups = { + loginModal: document.querySelector('[data-testid="login-modal"], .modal, [role="dialog"]'), + stayLoggedOut: Array.from(document.querySelectorAll('a')).find( + (link) => link.textContent?.toLowerCase().includes('stay logged out') && link.offsetParent !== null + ), + stayLoggedOutExact: document.querySelector('a.text-token-text-secondary.mt-5.cursor-pointer.text-sm.font-semibold.underline'), + continueButton: Array.from(document.querySelectorAll('button')).find( + (btn) => btn.textContent?.toLowerCase().includes('continue') && btn.offsetParent !== null + ), + closeButton: document.querySelector('[data-testid="close-button"], .close, [aria-label*="Close"], [aria-label*="close"]'), + dismissButton: Array.from(document.querySelectorAll('button')).find( + (btn) => btn.textContent?.toLowerCase().includes('dismiss') && btn.offsetParent !== null + ), + }; + + return { + hasPopup: Object.values(popups).some(popup => popup !== null), + popups: Object.fromEntries( + Object.entries(popups).map(([key, element]) => [ + key, + element ? { + text: element.textContent?.trim(), + tagName: element.tagName, + className: element.className, + isVisible: element.offsetParent !== null + } : null + ]) + ) + }; + }); + + if (popupInfo.hasPopup) { + this.logger.debug('Popup detected, attempting to handle', { popupInfo }); + + let popupDismissed = false; + + // Try to click "Stay logged out" link first (exact selector) + if (popupInfo.popups.stayLoggedOutExact) { + try { + await this.browserManager.page.click('a.text-token-text-secondary.mt-5.cursor-pointer.text-sm.font-semibold.underline'); + this.logger.debug('Clicked "Stay logged out" link (exact selector)'); + popupDismissed = true; + } catch (error) { + this.logger.debug('Failed to click exact "Stay logged out" selector', { error: error.message }); + } + } + + // Try to click "Stay logged out" link (text-based) + if (!popupDismissed && popupInfo.popups.stayLoggedOut) { + try { + await this.browserManager.page.evaluate(() => { + const link = Array.from(document.querySelectorAll('a')).find( + (link) => link.textContent?.toLowerCase().includes('stay logged out') && link.offsetParent !== null + ); + if (link) link.click(); + }); + this.logger.debug('Clicked "Stay logged out" link (text-based)'); + popupDismissed = true; + } catch (error) { + this.logger.debug('Failed to click "Stay logged out" link', { error: error.message }); + } + } + + // Try to click continue button + if (!popupDismissed && popupInfo.popups.continueButton) { + try { + await this.browserManager.page.evaluate(() => { + const btn = Array.from(document.querySelectorAll('button')).find( + (btn) => btn.textContent?.toLowerCase().includes('continue') && btn.offsetParent !== null + ); + if (btn) btn.click(); + }); + this.logger.debug('Clicked continue button'); + popupDismissed = true; + } catch (error) { + this.logger.debug('Failed to click continue button', { error: error.message }); + } + } + + // Try to click close/dismiss buttons (including X buttons) + if (!popupDismissed && (popupInfo.popups.closeButton || popupInfo.popups.dismissButton)) { + try { + const closeSelectors = [ + '[data-testid="close-button"]', + '.close', + '[aria-label*="Close"]', + '[aria-label*="close"]', + 'button[aria-label*="Close"]', + 'button[aria-label*="close"]', + '[role="button"][aria-label*="Close"]', + '[role="button"][aria-label*="close"]', + 'svg[data-icon="x"]', + 'svg[data-icon="X"]', + 'button svg[data-icon="x"]', + 'button svg[data-icon="X"]' + ]; + + for (const selector of closeSelectors) { + try { + await this.browserManager.page.click(selector); + this.logger.debug('Clicked close button', { selector }); + popupDismissed = true; + break; + } catch (e) { + // Continue to next selector + } + } + + // Try JavaScript fallback for X buttons + if (!popupDismissed) { + try { + const clicked = await this.browserManager.page.evaluate(() => { + // Look for any button with X icon or close text + const closeButtons = Array.from(document.querySelectorAll('button, [role="button"]')).filter(btn => { + const text = btn.textContent?.toLowerCase() || ''; + const ariaLabel = btn.getAttribute('aria-label')?.toLowerCase() || ''; + return text.includes('close') || text.includes('x') || ariaLabel.includes('close') || ariaLabel.includes('x'); + }); + + if (closeButtons.length > 0) { + closeButtons[0].click(); + return true; + } + return false; + }); + if (clicked) { + this.logger.debug('Clicked close button via JavaScript fallback'); + popupDismissed = true; + } + } catch (e) { + // Continue to next method + } + } + } catch (error) { + this.logger.debug('Failed to click close/dismiss buttons', { error: error.message }); + } + } + + // Try to remove popup via JavaScript as last resort + if (!popupDismissed) { + try { + await this.browserManager.page.evaluate(() => { + const modals = document.querySelectorAll('[data-testid="login-modal"], .modal, [role="dialog"]'); + modals.forEach(modal => { + if (modal.style) { + modal.style.display = 'none'; + modal.style.visibility = 'hidden'; + } + if (modal.remove) { + modal.remove(); + } + }); + }); + this.logger.debug('Removed popup via JavaScript'); + popupDismissed = true; + } catch (error) { + this.logger.debug('Failed to remove popup via JavaScript', { error: error.message }); + } + } + + // After dismissing popup, wait for textarea to be accessible + if (popupDismissed) { + await this.browserManager.delay(500); // Give time for popup to disappear + + // Verify textarea is now accessible + try { + await this.browserManager.waitForElement(this.selectors.textArea, { + timeout: 3000, + }); + this.logger.debug('Popup dismissed and textarea is now accessible'); + return true; + } catch (error) { + this.logger.warn('Popup dismissed but textarea still not accessible', { + error: error.message, + }); + // Continue polling + } + } + } else { + // No popup, check if we can proceed (textarea is available) + const canProceed = await this.browserManager.page.evaluate(() => { + const textarea = document.querySelector('textarea[data-id="root"], #prompt-textarea, [data-testid="composer-text-input"]'); + return textarea && textarea.offsetParent !== null; + }); + + if (canProceed) { + this.logger.debug('No popup blocking, textarea accessible'); + return true; + } + } + + await this.browserManager.delay(pollInterval); + } + + // Final check: is textarea accessible now? + const finalCheck = await this.browserManager.page.evaluate(() => { + const textarea = document.querySelector('textarea[data-id="root"], #prompt-textarea, [data-testid="composer-text-input"]'); + const hasPopup = document.querySelector('[data-testid="login-modal"], .modal, [role="dialog"]') !== null; + return { + textareaAccessible: textarea && textarea.offsetParent !== null, + hasPopup: hasPopup + }; + }); + + if (finalCheck.hasPopup) { + this.logger.warn('Popup handling timeout - popup still present', { maxWaitMs }); + } + + if (!finalCheck.textareaAccessible) { + this.logger.warn('Popup handling timeout - textarea not accessible', { maxWaitMs }); + } + + return finalCheck.textareaAccessible; + } catch (error) { + this.logger.error('Error handling popup', { error: error.message }); + return false; + } + } + + /** + * Reset browser state by opening a fresh ChatGPT tab + */ + async resetBrowserState() { + this.logger.info('Resetting browser state - opening fresh ChatGPT tab'); + try { + // Get current cookies before closing page to preserve session + const cookies = await this.browserManager.page.cookies(); + this.logger.debug('Saved session cookies', { count: cookies.length }); + + // Close current page and open fresh one + if (this.browserManager.page) { + await this.browserManager.page.close(); + } + + // Create new page + this.browserManager.page = await this.browserManager.browser.newPage(); + + // Restore cookies to preserve session + if (cookies.length > 0) { + await this.browserManager.page.setCookie(...cookies); + this.logger.debug('Restored session cookies'); + } + + // Set user agent again + await this.browserManager.page.setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + + // Set timeouts + this.browserManager.page.setDefaultTimeout( + this.browserManager.config.timeout + ); + this.browserManager.page.setDefaultNavigationTimeout( + this.browserManager.config.timeout + ); + + // Navigate to ChatGPT + await this.browserManager.navigateToUrl(this.urls.chat); + + // Wait for page to stabilize + await this.browserManager.delay(1000); + + this.logger.info('Browser state reset completed'); + } catch (error) { + this.logger.error('Failed to reset browser state', { + error: error.message, + }); + throw new Error(`Browser reset failed: ${error.message}`); + } + } +} + +export default GrokProvider; diff --git a/src/textgenhub/webui/grok/grok.py b/src/textgenhub/webui/grok/grok.py new file mode 100644 index 0000000..18e371d --- /dev/null +++ b/src/textgenhub/webui/grok/grok.py @@ -0,0 +1,38 @@ +""" +Grok provider - Simple and clean implementation +""" +from pathlib import Path +from ...core.provider import SimpleProvider + + +def ask(prompt: str, headless: bool = True, remove_cache: bool = True, debug: bool = False, timeout: int = 120, typing_speed: float | None = None) -> str: + """ + Send a prompt to Grok and get a response. + + Args: + prompt (str): The prompt to send to Grok + headless (bool): Whether to run browser in headless mode + remove_cache (bool): Whether to remove browser cache + debug (bool): Whether to enable debug mode + timeout (int): Timeout in seconds for the operation + typing_speed (float | None): Typing speed in seconds per character (default: None for instant paste, > 0 for character-by-character typing) + + Returns: + str: The response from Grok + """ + provider = SimpleProvider("grok", "grok_cli.js", script_dir=Path(__file__).parent) + return provider.ask(prompt, headless, remove_cache, debug, timeout, typing_speed) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Grok CLI via Node.js wrapper") + parser.add_argument("--prompt", type=str, required=True, help="Prompt to send to Grok") + parser.add_argument("--headless", type=lambda x: x.lower() == "true", default=True, help="Run Node in headless mode") + parser.add_argument("--remove-cache", type=lambda x: x.lower() == "false", default=False, help="Remove cache on cleanup") + + args = parser.parse_args() + + resp = ask(args.prompt, headless=args.headless, remove_cache=args.remove_cache) + print("Response returned from ask method: ", resp) diff --git a/src/textgenhub/webui/grok/grok_cli.js b/src/textgenhub/webui/grok/grok_cli.js new file mode 100644 index 0000000..4707456 --- /dev/null +++ b/src/textgenhub/webui/grok/grok_cli.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +'use strict'; + +import GrokProvider from './grok.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +(async () => { + const argv = yargs(hideBin(process.argv)) + .option('prompt', { type: 'string', demandOption: false }) + .option('expected', { type: 'string', demandOption: false }) + .option('headless', { type: 'boolean', default: true }) + .option('remove-cache', { type: 'boolean', default: false }) + .option('continuous', { type: 'boolean', default: false }) + .option('debug', { type: 'boolean', default: true }) // Force debug true + .option('output-format', { type: 'string', choices: ['json', 'html'], default: 'json' }) + .option('typing-speed', { type: 'number', default: null }) + .argv; + + // Always enable debug mode for investigation + const provider = new GrokProvider({ + headless: argv.headless, + removeCache: argv['remove-cache'], + continuous: argv.continuous, + debug: true + }); + + try { + await provider.initialize(); + + if (argv.continuous) { + // Continuous mode: read prompts from stdin + const readline = require('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + rl.on('line', async (line) => { + const prompt = line.trim(); + if (prompt) { + try { + const response = await provider.generateContent(prompt, { typingSpeed: argv['typing-speed'] }); + console.log(JSON.stringify({ response, prompt })); + } catch (err) { + let artifactPath = null; + if (typeof provider.saveHtmlArtifact === 'function') { + artifactPath = await provider.saveHtmlArtifact('continuous-error'); + } + // Always print JSON to stdout + console.log(JSON.stringify({ error: err.message, prompt, artifactPath })); + } + } + }); + rl.on('close', async () => { + await provider.cleanup(); + process.exit(0); + }); + } else { + // Single prompt mode + let response = null; + let artifactPath = null; + let errorObj = null; + try { + response = await provider.generateContent(argv.prompt, { typingSpeed: argv['typing-speed'] }); + // Validate response if expected is provided + if (argv.expected && response.trim() !== argv.expected.trim()) { + if (typeof provider.saveHtmlArtifact === 'function') { + artifactPath = await provider.saveHtmlArtifact('wrong-answer'); + } + throw new Error(`Grok regression test failed: expected "${argv.expected}", got "${response}"`); + } + // Success: print response based on format + if (argv['output-format'] === 'html') { + // For HTML output, try to get HTML content if available + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(html || response); + } else { + // JSON output with metadata + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(JSON.stringify({ + response, + html + }, null, 2)); + } + } catch (err) { + if (!artifactPath && typeof provider.saveHtmlArtifact === 'function') { + artifactPath = await provider.saveHtmlArtifact('error'); + } + errorObj = { error: err.message, artifactPath }; + // Always print JSON to stdout + console.log(JSON.stringify(errorObj)); + // Exit with error code + process.exit(1); + } + await provider.cleanup(); + } + } catch (err) { + // Print full error context for debugging + console.error('[Grok CLI ERROR]', { + message: err.message, + stack: err.stack, + originalError: err.originalError ? { + message: err.originalError.message, + stack: err.originalError.stack + } : undefined + }); + process.exit(1); + } +})(); diff --git a/src/textgenhub/webui/perplexity/__init__.py b/src/textgenhub/webui/perplexity/__init__.py new file mode 100644 index 0000000..02eb2a7 --- /dev/null +++ b/src/textgenhub/webui/perplexity/__init__.py @@ -0,0 +1,11 @@ +from .perplexity import ask + + +class Perplexity: + """Perplexity provider class""" + + def chat(self, prompt: str) -> str: + return ask(prompt) + + +__all__ = ["ask", "Perplexity"] diff --git a/src/textgenhub/webui/perplexity/perplexity.js b/src/textgenhub/webui/perplexity/perplexity.js new file mode 100644 index 0000000..442989c --- /dev/null +++ b/src/textgenhub/webui/perplexity/perplexity.js @@ -0,0 +1,397 @@ +/** + * Perplexity Provider - Browser automation for Perplexity AI web interface + */ + +'use strict'; + +import path from 'path'; +import BaseLLMProvider from '../../core/base-provider.js'; + +class PerplexityProvider extends BaseLLMProvider { + constructor(config = {}) { + super('perplexity', config); + + this.browserManager = null; + this.isLoggedIn = false; + this.sessionTimeout = config.sessionTimeout || 3600000; // 1 hour + this.lastSessionCheck = 0; + + this.removeCache = config.removeCache !== undefined ? config.removeCache : true; + + // Very specific selectors to avoid email/popup inputs + this.selectors = { + textArea: 'div[contenteditable="true"]:not([type="email"]):not([placeholder*="email"]):not([class*="footer"]), textarea:not([type="email"]), input[type="text"]:not([type="email"])', + submitButton: 'button[type="submit"], button[aria-label*="Ask"], button[aria-label*="Search"], button[aria-label*="Send"]', + responseContainer: 'div[class*="answer"], div[class*="response"], div[class*="result"]', + }; + + this.urls = { + chat: 'https://www.perplexity.ai/', + }; + + this.config = { + headless: config.headless !== undefined ? config.headless : true, + timeout: config.timeout || 60000, + sessionTimeout: config.sessionTimeout || 3600000, + userDataDir: config.userDataDir || path.join(process.cwd(), 'temp', 'perplexity-session'), + ...config, + }; + } + + async initialize() { + try { + this.logger?.info('Initializing Perplexity provider...'); + const BrowserManager = (await import('../../core/browser-manager.cjs')).default; + const browserConfig = { + headless: this.config.headless, + timeout: this.config.timeout, + userDataDir: this.config.userDataDir, + debug: this.config.debug, + }; + this.browserManager = new BrowserManager(browserConfig); + await this.browserManager.initialize(); + this.logger?.info('Navigating to Perplexity Chat...', { url: this.urls.chat }); + await this.browserManager.navigateToUrl(this.urls.chat); + + // Wait for the page to fully load, handling Cloudflare challenges + await this.waitForInterfaceReady(); + + this.isInitialized = true; + this.logger?.info('Perplexity provider initialized successfully'); + } catch (error) { + throw await this.handleError(error, 'initialization'); + } + } + + /** + * Wait for Perplexity interface to be ready, handling Cloudflare challenges + */ + async waitForInterfaceReady() { + const maxWaitTime = 60000; // 60 seconds to handle Cloudflare + const checkInterval = 2000; // Check every 2 seconds + const startTime = Date.now(); + + this.logger?.info('Waiting for Perplexity interface to load...'); + + while (Date.now() - startTime < maxWaitTime) { + try { + // Check if we're still on a Cloudflare challenge page + const isCloudflareChallenge = await this.browserManager.page.evaluate(() => { + const title = document.title.toLowerCase(); + const bodyText = document.body?.textContent?.toLowerCase() || ''; + return title.includes('just a moment') || + bodyText.includes('verifying you are human') || + bodyText.includes('checking your browser'); + }); + + if (isCloudflareChallenge) { + this.logger?.debug('Cloudflare challenge detected, waiting...'); + await this.browserManager.delay(checkInterval); + continue; + } + + // Check if the actual Perplexity interface has loaded + const interfaceReady = await this.browserManager.page.evaluate(() => { + // Look for Perplexity-specific elements that indicate the interface is loaded + const hasPerplexityElements = !!( + document.querySelector('div[contenteditable="true"]') || + document.querySelector('textarea') || + document.querySelector('input[type="text"]') || + document.querySelector('[class*="perplexity"]') || + document.querySelector('[data-testid*="perplexity"]') + ); + + // Also check that we're on the correct domain + const isCorrectDomain = window.location.hostname === 'www.perplexity.ai'; + + return hasPerplexityElements && isCorrectDomain; + }); + + if (interfaceReady) { + // Double-check by trying to find the input field + try { + await this.browserManager.page.waitForSelector( + 'div[contenteditable="true"], textarea, input[type="text"]', + { timeout: 5000 } + ); + this.logger?.info('Perplexity interface ready'); + return; + } catch (e) { + this.logger?.debug('Interface elements found but input not ready yet'); + } + } + + await this.browserManager.delay(checkInterval); + } catch (e) { + this.logger?.debug('Error checking interface readiness', { error: e.message }); + await this.browserManager.delay(checkInterval); + } + } + + throw new Error('Timeout waiting for Perplexity interface to load'); + } + + async generateContent(prompt, options = {}) { + if (!this.browserManager || !this.browserManager.page) { + throw await this.handleError(new Error('Browser not initialized'), 'content generation'); + } + try { + this.validatePrompt(prompt); + await this.applyRateLimit(); + + const startTime = Date.now(); + if (this.config.debug) this.logger.info('Sending prompt to Perplexity', { + promptLength: prompt.length, + }); + + // First clear the page state + try { + await this.browserManager.page.evaluate(() => { + // Try to find and click "New Chat" button first + const newChatBtn = Array.from(document.querySelectorAll('button')).find(btn => + btn.textContent?.toLowerCase().includes('new chat') || + btn.getAttribute('aria-label')?.toLowerCase().includes('new chat') + ); + if (newChatBtn) { + newChatBtn.click(); + return; + } + + // If no New Chat button, try to clear existing responses + const main = document.querySelector('main'); + if (main) { + const responses = main.querySelectorAll('div[class*="answer"], div[class*="response"]'); + responses.forEach(el => el.parentNode?.removeChild(el)); + } + }); + await this.browserManager.delay(1000); + } catch (e) { + if (this.config.debug) this.logger.debug('Error clearing page state', { error: e.message }); + } + + // Handle any popups + try { + await this.browserManager.page.evaluate(() => { + // Click all visible close buttons + const closeButtons = Array.from(document.querySelectorAll( + 'button[aria-label*="Close"], button[aria-label*="Dismiss"], [class*="close"]' + )).filter(el => el.offsetParent !== null); + closeButtons.forEach(btn => btn.click()); + + // Remove any remaining popups/dialogs + const popups = Array.from(document.querySelectorAll( + 'div[role="dialog"], [class*="popup"], [class*="modal"], [class*="overlay"]' + )).filter(el => el.offsetParent !== null); + popups.forEach(popup => popup.parentNode?.removeChild(popup)); + }); + await this.browserManager.delay(500); + } catch (e) { + if (this.config.debug) this.logger.debug('Error handling popups', { error: e.message }); + } + + // Find and prepare input + try { + const inputReady = await this.browserManager.page.evaluate(() => { + // Look for various input types + const possibleInputs = [ + // Contenteditable divs + ...Array.from(document.querySelectorAll('div[contenteditable="true"]')), + // Textareas + ...Array.from(document.querySelectorAll('textarea')), + // Text inputs + ...Array.from(document.querySelectorAll('input[type="text"]')), + ].filter(el => { + const isVisible = el.offsetParent !== null; + const notInPopup = !el.closest('div[role="dialog"]') && !el.closest('[class*="popup"]') && !el.closest('[class*="modal"]'); + const notEmail = !el.getAttribute('placeholder')?.toLowerCase().includes('email') && + !el.getAttribute('type')?.includes('email') && + !el.className?.toLowerCase().includes('email'); + const notHidden = !el.getAttribute('hidden') && el.style.display !== 'none' && el.style.visibility !== 'hidden'; + return isVisible && notInPopup && notEmail && notHidden; + }); + + if (possibleInputs.length > 0) { + // Prefer the first visible input + const input = possibleInputs[0]; + if (input.tagName.toLowerCase() === 'div' && input.getAttribute('contenteditable') === 'true') { + input.innerHTML = ''; + input.focus(); + } else { + input.value = ''; + input.focus(); + } + return true; + } + return false; + }); + + if (!inputReady) { + throw new Error('Could not find appropriate input field'); + } + + // Input the prompt using direct assignment or typing based on typingSpeed + const typingSpeed = options.typingSpeed; + if (this.config.debug) this.logger.debug(`${typingSpeed === null || typingSpeed === 0 ? 'Pasting' : 'Typing'} prompt`); + + if (typingSpeed === null || typingSpeed === 0) { + // Paste the prompt using direct value assignment for performance + await this.browserManager.page.evaluate((prompt) => { + const textarea = document.querySelector('textarea'); + if (textarea) { + textarea.value = prompt; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + const contenteditable = document.querySelector('[contenteditable="true"]'); + if (contenteditable) { + contenteditable.textContent = prompt; + contenteditable.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + }, prompt); + } else { + // Use keyboard typing + await this.browserManager.page.keyboard.type(prompt); + } + if (this.config.debug) this.logger.debug(`Prompt ${typingSpeed === null || typingSpeed === 0 ? 'pasted' : 'typed'} successfully`); + + // Submit the prompt - try multiple methods + let submitted = false; + try { + // Try Enter key first + await this.browserManager.page.keyboard.press('Enter'); + submitted = true; + if (this.config.debug) this.logger.debug('Submitted with Enter key'); + } catch (e) { + if (this.config.debug) this.logger.debug('Enter key failed, trying button click'); + } + + if (!submitted) { + // Try clicking submit button + try { + await this.browserManager.page.evaluate(() => { + const buttons = Array.from(document.querySelectorAll('button')).filter(btn => { + const text = btn.textContent?.toLowerCase() || ''; + const ariaLabel = btn.getAttribute('aria-label')?.toLowerCase() || ''; + return text.includes('ask') || text.includes('search') || text.includes('send') || + ariaLabel.includes('ask') || ariaLabel.includes('search') || ariaLabel.includes('send') || + btn.getAttribute('type') === 'submit'; + }); + if (buttons.length > 0) { + buttons[0].click(); + return true; + } + return false; + }); + submitted = true; + if (this.config.debug) this.logger.debug('Submitted with button click'); + } catch (e) { + if (this.config.debug) this.logger.debug('Button click failed'); + } + } + + if (!submitted) { + throw new Error('Could not submit the prompt'); + } + + // Wait for response + const response = await this.waitForResponse(); + + const duration = Date.now() - startTime; + this.logRequest(prompt, response, duration); + + return response; + } catch (error) { + this.logger.error('Failed to generate content', { error: error.message }); + throw await this.handleError(error, 'content generation'); + } + } catch (error) { + this.logger.error('Error in generateContent', { error: error.message }); + throw await this.handleError(error, 'content generation'); + } + // removed stray closing brace after generateContent + } + + async waitForResponse(timeout = 60000) { + const startTime = Date.now(); + let response = ''; + + while (Date.now() - startTime < timeout) { + try { + response = await this.browserManager.page.evaluate(() => { + console.log('Checking for response elements...'); + const allDivs = document.querySelectorAll('div'); + console.log('Total divs found:', allDivs.length); + + const responseDivs = Array.from(document.querySelectorAll( + // Selectors for finding responses in the DOM + 'div[class*="CopyableOutputText"], ' + // Primary answer text + 'div[data-testid="answer-text"], ' + // Secondary answer test container + 'div[class*="answer-text"], ' + // Generic answer containers + 'div[class*="perplexity-answer"], ' + // Branded answer containers + 'div[class*="prose"] > p' // Markdown-rendered responses + )).filter(el => { + const isVisible = el.offsetParent !== null; + const hasContent = el.textContent.trim().length > 0; + const notInput = !el.getAttribute('contenteditable'); + return isVisible && hasContent && notInput; + }); + + console.log('Found response divs:', responseDivs.length); + + if (responseDivs.length > 0) { + // Get the last visible response + const lastResponse = responseDivs[responseDivs.length - 1]; + console.log('Response element classes:', lastResponse.className); + return lastResponse.textContent.trim(); + } + + return ''; + }); + + if (response.length > 0) { + const cleanedResponse = this.cleanResponse(response); + + return cleanedResponse; + } + + await this.browserManager.delay(1000); + } catch (e) { + console.error('Error while waiting for response:', e); + await this.browserManager.delay(1000); + } + } + + throw new Error('Timeout waiting for response'); + } + + cleanResponse(text) { + return text + .replace(/Copy|Share|Export|Related/g, '') + .replace(/\s+/g, ' ') + .trim(); + } + + async cleanup() { + if (this.browserManager) { + if (this.removeCache) { + await this.browserManager.cleanupCache(); + } + await this.browserManager.close(); + this.browserManager = null; + } + this.isInitialized = false; + } + + async isHealthy() { + try { + if (!this.browserManager?.page) return false; + const inputVisible = await this.browserManager.page.$(this.selectors.textArea); + return !!inputVisible; + } catch (error) { + return false; + } + } +} + +export default PerplexityProvider; diff --git a/src/textgenhub/webui/perplexity/perplexity.py b/src/textgenhub/webui/perplexity/perplexity.py new file mode 100644 index 0000000..fe9c2c6 --- /dev/null +++ b/src/textgenhub/webui/perplexity/perplexity.py @@ -0,0 +1,38 @@ +""" +Perplexity provider - Simple and clean implementation +""" +from pathlib import Path +from ...core.provider import SimpleProvider + + +def ask(prompt: str, headless: bool = True, remove_cache: bool = True, debug: bool = False, timeout: int = 120, typing_speed: float | None = None) -> str: + """ + Send a prompt to Perplexity and get a response. + + Args: + prompt (str): The prompt to send to Perplexity + headless (bool): Whether to run browser in headless mode + remove_cache (bool): Whether to remove browser cache + debug (bool): Whether to enable debug mode + timeout (int): Timeout in seconds for the operation + typing_speed (float | None): Typing speed in seconds per character (default: None for instant paste, > 0 for character-by-character typing) + + Returns: + str: The response from Perplexity + """ + provider = SimpleProvider("perplexity", "perplexity_cli.js", script_dir=Path(__file__).parent) + return provider.ask(prompt, headless, remove_cache, debug, timeout, typing_speed) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Perplexity AI CLI via Node.js wrapper") + parser.add_argument("--prompt", type=str, required=True, help="Prompt to send to Perplexity AI") + parser.add_argument("--headless", type=lambda x: x.lower() == "true", default=True, help="Run Node in headless mode") + parser.add_argument("--remove-cache", type=lambda x: x.lower() == "false", default=False, help="Remove cache on cleanup") + + args = parser.parse_args() + + resp = ask(args.prompt, headless=args.headless, remove_cache=args.remove_cache) + print("Response returned from ask method: ", resp) diff --git a/src/textgenhub/webui/perplexity/perplexity_cli.js b/src/textgenhub/webui/perplexity/perplexity_cli.js new file mode 100644 index 0000000..c12c29e --- /dev/null +++ b/src/textgenhub/webui/perplexity/perplexity_cli.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +'use strict'; + +import PerplexityProvider from './perplexity.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +(async () => { + const argv = yargs(hideBin(process.argv)) + .option('prompt', { type: 'string', demandOption: true }) + .option('headless', { type: 'boolean', default: true }) + .option('remove-cache', { type: 'boolean', default: false }) + .option('debug', { type: 'boolean', default: false }) + .option('output-format', { type: 'string', choices: ['json', 'html'], default: 'json' }) + .option('typing-speed', { type: 'number', default: null }) + .argv; + + const provider = new PerplexityProvider({ + headless: argv.headless, + removeCache: argv['remove-cache'], + debug: argv.debug + }); + + try { + await provider.initialize(); + const response = await provider.generateContent(argv.prompt, { typingSpeed: argv['typing-speed'] }); + + if (argv['output-format'] === 'html') { + // For HTML output, try to get HTML content if available + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(html || response); + } else { + // JSON output with metadata + const html = provider.getLastHtml ? await provider.getLastHtml() : ''; + console.log(JSON.stringify({ + response, + html + }, null, 2)); + } + + await provider.cleanup(); + } catch (err) { + // Always output a JSON error object for CI artifact capture + let artifactPath = err.artifactPath || (err.originalError && err.originalError.artifactPath); + console.log(JSON.stringify({ + error: err.message || String(err), + stack: err.stack, + artifactPath + })); + process.exit(1); + } +})(); diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/deepseek/__init__.py b/tests/api/deepseek/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/deepseek/test_deepseek.py b/tests/api/deepseek/test_deepseek.py new file mode 100644 index 0000000..0feb731 --- /dev/null +++ b/tests/api/deepseek/test_deepseek.py @@ -0,0 +1,44 @@ +import pytest +import os +import requests as req +from unittest.mock import patch, MagicMock +from textgenhub.api.deepseek import ask, DeepSeekAPI + +# Single entry point for response simulation +def mock_post(status=200, content="hello", exception=None): + if exception: + return patch("textgenhub.api.deepseek.deepseek.requests.post", side_effect=exception) + m = MagicMock(status_code=status) + m.json.return_value = {"choices": [{"message": {"content": content}}]} + return patch("textgenhub.api.deepseek.deepseek.requests.post", return_value=m) + +def test_ask_behavior(): + # 1. Standard success path & headers + with mock_post(content="hello") as m: + assert ask("hi", api_key="sk-abc") == "hello" + kwargs = m.call_args.kwargs + assert kwargs["headers"]["Authorization"] == "Bearer sk-abc" + assert "temperature" not in kwargs["json"] + + # 2. System prompt insertion + with mock_post() as m: + ask("hi", api_key="k", system_prompt="be helpful") + assert m.call_args.kwargs["json"]["messages"][0] == {"role": "system", "content": "be helpful"} + + # 3. Validation and errors + with patch.dict(os.environ, {}, clear=True), pytest.raises(ValueError, match="API key"): + ask("hi") + + with mock_post(status=401), pytest.raises(RuntimeError, match="authentication"): + ask("hi", api_key="k") + + with mock_post(status=429), pytest.raises(RuntimeError, match="rate limit"): + ask("hi", api_key="k") + + with mock_post(exception=req.exceptions.ConnectionError()), pytest.raises(RuntimeError, match="connect"): + ask("hi", api_key="k") + +def test_class_chat_delegates(): + with patch("textgenhub.api.deepseek.deepseek.ask", return_value="r") as m: + assert DeepSeekAPI(api_key="k").chat("hi") == "r" + assert m.call_args.kwargs["prompt"] == "hi" diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_provider.py b/tests/core/test_provider.py new file mode 100644 index 0000000..9b5d0bf --- /dev/null +++ b/tests/core/test_provider.py @@ -0,0 +1,48 @@ +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock +from textgenhub.core.provider import SimpleProvider + +def _make_provider(name="chatgpt"): + return SimpleProvider(name, f"{name}_cli.js", script_dir=Path(".")) + +def mock_popen(output=b'{"response": "ok"}', stdout_none=False): + proc = MagicMock() + proc.stdout = None if stdout_none else MagicMock() + if not stdout_none: + proc.stdout.read.return_value = output + proc.wait.return_value = None + return patch("subprocess.Popen", return_value=MagicMock(__enter__=MagicMock(return_value=proc))) + +def test_ask_success_and_errors(): + # 1. Standard success path + with mock_popen(b'{"response": "hello"}'): + assert _make_provider().ask("hi") == "hello" + + # 2. Validation & Parse errors + with mock_popen(b"just logs"), pytest.raises(RuntimeError, match="did not produce JSON"): + _make_provider().ask("hi") + + with mock_popen(stdout_none=True), pytest.raises(RuntimeError, match="stdout is None"): + _make_provider().ask("hi") + + # ponytail: ValueError is checked before Popen fires, but mock ensures isolation + with mock_popen(), pytest.raises(ValueError, match="Prompt is required"): + _make_provider("deepseek").ask(None) + + # 3. Close flag shortcut + with mock_popen(b""): + assert _make_provider("chatgpt").ask(None, close=True) == "" + +@pytest.mark.parametrize("name, expected_present, expected_absent", [ + ("chatgpt", ["--timeout"], ["--headless"]), + ("deepseek", ["--headless"], []), +]) +def test_provider_cli_flags(name, expected_present, expected_absent): + with mock_popen() as m: + _make_provider(name).ask("hi") + cmd = m.call_args[0][0] + for flag in expected_present: + assert flag in cmd + for flag in expected_absent: + assert flag not in cmd diff --git a/tests/local/__init__.py b/tests/local/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/local/ollama/__init__.py b/tests/local/ollama/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/local/ollama/test_ollama.py b/tests/local/ollama/test_ollama.py new file mode 100644 index 0000000..18b086d --- /dev/null +++ b/tests/local/ollama/test_ollama.py @@ -0,0 +1,52 @@ +import pytest +import requests as req +from unittest.mock import patch, MagicMock +from textgenhub.local.ollama import ask, Ollama, list_models + +def mock_http(method="post", status=200, json_data=None, exception=None): + target = f"textgenhub.local.ollama.ollama.requests.{method}" + if exception: + return patch(target, side_effect=exception) + m = MagicMock(status_code=status) + if json_data: + m.json.return_value = json_data + return patch(target, return_value=m) + +def test_ask_behavior(): + # 1. Success paths, defaults, and payload shapes + res = {"message": {"content": "hello"}} + with mock_http("post", json_data=res) as m: + assert ask("hi") == "hello" + payload = m.call_args.kwargs["json"] + assert payload["model"] == "llama3" + + # 2. System prompt + with mock_http("post", json_data={"message": {"content": "hi"}}) as m: + ask("hi", system_prompt="be terse") + msgs = m.call_args.kwargs["json"]["messages"] + assert msgs[0] == {"role": "system", "content": "be terse"} + assert msgs[1]["role"] == "user" + + # 3. Errors + with mock_http("post", status=500) as m: + m.return_value.text = "err" + with pytest.raises(RuntimeError, match="500"): + ask("hi") + + with mock_http("post", exception=req.exceptions.ConnectionError()), pytest.raises(RuntimeError, match="Ollama"): + ask("hi") + +def test_list_models_behavior(): + # 1. Success & sorting + models_payload = {"models": [{"name": "z"}, {"name": "a"}]} + with mock_http("get", json_data=models_payload): + assert list_models() == ["a", "z"] + + # 2. Resilience + with mock_http("get", exception=req.exceptions.ConnectionError()): + assert list_models() == [] + +def test_ollama_class_chat_delegates(): + with patch("textgenhub.local.ollama.ollama.ask", return_value="r") as m: + assert Ollama().chat("hi") == "r" + assert m.call_args.kwargs["prompt"] == "hi" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_scrape_response.py b/tests/utils/test_scrape_response.py new file mode 100644 index 0000000..172d0ea --- /dev/null +++ b/tests/utils/test_scrape_response.py @@ -0,0 +1,25 @@ +import pytest +from textgenhub.utils.scrape_response import extract_response_json + + +def test_extracts_response(): + assert extract_response_json('{"response": "hello"}') == "hello" + + +def test_strips_chatgpt_said_prefix(): + result = extract_response_json('{"response": "ChatGPT said: actual answer"}') + assert result == "actual answer" + + +def test_ignores_non_json_lines(): + stdout = "debug log\n[INFO] something\n{\"response\": \"answer\"}" + assert extract_response_json(stdout) == "answer" + + +def test_raises_when_no_json(): + with pytest.raises(ValueError): + extract_response_json("no json here") + + +def test_empty_response_value(): + assert extract_response_json('{"response": ""}') == "" diff --git a/tests/webui/__init__.py b/tests/webui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/webui/test_webui_providers.py b/tests/webui/test_webui_providers.py new file mode 100644 index 0000000..663f764 --- /dev/null +++ b/tests/webui/test_webui_providers.py @@ -0,0 +1,18 @@ +from unittest.mock import patch, MagicMock +import importlib + +def _mock_popen(): + proc = MagicMock() + proc.stdout.read.return_value = b'{"response": "ok"}' + proc.wait.return_value = None + return proc + +@patch("subprocess.Popen") +def test_webui_providers_wire_correctly(mock_popen): + mock_popen.return_value.__enter__.return_value = _mock_popen() + + providers = ["chatgpt", "deepseek", "grok", "perplexity"] + + for provider in providers: + module = importlib.import_module(f"textgenhub.webui.{provider}") + assert module.ask("hi") == "ok"