feat: Coolify deployment support and Telegram UI/UX enhancements
This PR introduces comprehensive support for Coolify deployment and several improvements to the Telegram channel: Coolify Deployment: - Added COOLIFY.md guide with 3 configuration methods. - Added entrypoint-coolify.sh to generate config.json from environment variables. - Added Dockerfile.coolify and docker-compose-coolify.yml optimized for Coolify. - Support for full JSON configuration via PICOCLAW_CONFIG_JSON env var. Telegram Enhancements: - Persistent 'typing' indicator that repeats every 4s while AI is thinking. - Automatic registration of bot commands (/model, /models) on startup. - Consolidated /model command that supports 'provider/model' syntax for atomic switching. - Dynamic /models command that shows actually configured providers and active model. Configuration: - Improved AgentLoop to support hot-switching models and providers without restart. These changes improve cloud deployability and user experience in chat channels.
This commit is contained in:
parent
daf0683cea
commit
79497a13c1
7 changed files with 817 additions and 36 deletions
326
COOLIFY.md
Normal file
326
COOLIFY.md
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
# ☁️ Deploying PicoClaw on Coolify
|
||||||
|
|
||||||
|
Deploy PicoClaw as a self-hosted AI assistant on [Coolify](https://coolify.io) — the open-source Heroku/Vercel alternative.
|
||||||
|
|
||||||
|
> **Looking for the quick 3-step version?** See [README.md → Deploy on Coolify](README.md#%EF%B8%8F-deploy-on-coolify-3-steps)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
- A Coolify instance (v4+)
|
||||||
|
- A GitHub account (to fork the repo)
|
||||||
|
- At least one LLM API key (e.g., [Gemini](https://aistudio.google.com/apikey), [OpenRouter](https://openrouter.ai/keys))
|
||||||
|
|
||||||
|
## 🚀 Quick Deploy
|
||||||
|
|
||||||
|
### Step 1: Fork the Repository
|
||||||
|
|
||||||
|
Fork [mrbeandev/picoclaw](https://github.com/mrbeandev/picoclaw) to your GitHub account.
|
||||||
|
|
||||||
|
### Step 2: Create a New Service in Coolify
|
||||||
|
|
||||||
|
1. Go to your Coolify dashboard → **Projects** → select or create a project
|
||||||
|
2. Click **+ New** → **Docker Compose**
|
||||||
|
3. Connect your forked GitHub repo
|
||||||
|
4. Set the following:
|
||||||
|
- **Branch:** `deploy/coolify`
|
||||||
|
- **Docker Compose File:** `docker-compose-coolify.yml`
|
||||||
|
- **Base Directory:** `/` (root)
|
||||||
|
|
||||||
|
### Step 3: Configure Environment Variables
|
||||||
|
|
||||||
|
Go to **Environment Variables** tab and add your keys. See [Configuration](#-configuration) below.
|
||||||
|
|
||||||
|
### Step 4: Deploy!
|
||||||
|
|
||||||
|
Hit **Deploy** and wait for the build to complete (~30 seconds).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
PicoClaw on Coolify supports **3 configuration methods**. The entrypoint script checks them in order — **first match wins**.
|
||||||
|
|
||||||
|
### Which Method Should I Use?
|
||||||
|
|
||||||
|
| | Method 1: JSON Env Var | Method 2: Mounted File | Method 3: Individual Env Vars |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Difficulty** | Medium | Easy | Easiest |
|
||||||
|
| **Flexibility** | ✅ Full control | ✅ Full control | ⚠️ Limited |
|
||||||
|
| **Custom providers (Ollama, vLLM)** | ✅ Yes | ✅ Yes | ❌ No |
|
||||||
|
| **Allow-lists** | ✅ Yes | ✅ Yes | ✅ Yes (comma-separated) |
|
||||||
|
| **Feishu, DingTalk, QQ, OneBot** | ✅ Yes | ✅ Yes | ❌ No |
|
||||||
|
| **Custom API base URLs** | ✅ Yes | ✅ Yes | ❌ No |
|
||||||
|
| **Requires JSON minification** | ⚠️ Yes | ❌ No | ❌ No |
|
||||||
|
| **Edit without rebuild** | ✅ Redeploy only | ✅ Restart only | ✅ Redeploy only |
|
||||||
|
| **Pretty-printed JSON** | ❌ Must minify | ✅ Yes | N/A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Method 1: Full JSON Config (Most Flexible) ⭐
|
||||||
|
|
||||||
|
**Best for:** Full control, custom providers (Ollama, vLLM), complex setups.
|
||||||
|
|
||||||
|
Paste your **entire** `config.json` as a single environment variable:
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| `PICOCLAW_CONFIG_JSON` | `{"agents":{"defaults":{"provider":"gemini",...}},...}` |
|
||||||
|
|
||||||
|
#### Example: Gemini + Telegram + Ollama
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"provider": "gemini",
|
||||||
|
"model": "gemini-2.5-flash-lite",
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tool_iterations": 20,
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"restrict_to_workspace": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"gemini": {
|
||||||
|
"api_key": "AIzaSy..."
|
||||||
|
},
|
||||||
|
"vllm": {
|
||||||
|
"api_key": "dummy",
|
||||||
|
"api_base": "http://your-ollama-server:11434/v1"
|
||||||
|
},
|
||||||
|
"openrouter": {
|
||||||
|
"api_key": "sk-or-..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "123456:ABC-DEF...",
|
||||||
|
"allow_from": ["your_telegram_user_id"]
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"duckduckgo": { "enabled": true, "max_results": 5 },
|
||||||
|
"brave": { "enabled": false, "api_key": "", "max_results": 5 }
|
||||||
|
},
|
||||||
|
"firecrawl": { "enabled": false, "api_key": "", "api_base": "https://api.firecrawl.dev/v1" },
|
||||||
|
"serpapi": { "enabled": false, "api_key": "", "max_results": 10 }
|
||||||
|
},
|
||||||
|
"heartbeat": { "enabled": true, "interval": 30 },
|
||||||
|
"gateway": { "host": "0.0.0.0", "port": 18790 },
|
||||||
|
"devices": { "enabled": false, "monitor_usb": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⚠️ Important: You MUST minify the JSON!
|
||||||
|
|
||||||
|
Coolify environment variables are single-line. You need to compress the JSON into **one line** before pasting.
|
||||||
|
|
||||||
|
**🔧 JSON Tools for Minifying:**
|
||||||
|
|
||||||
|
| Tool | Type | URL |
|
||||||
|
|------|------|-----|
|
||||||
|
| **JSON Minifier** | Web | [jsonformatter.org/json-minify](https://jsonformatter.org/json-minify) |
|
||||||
|
| **JSON Formatter** | Web | [jsonformatter.curiousconcept.com](https://jsonformatter.curiousconcept.com/) |
|
||||||
|
| **JSON Editor Online** | Web | [jsoneditoronline.org](https://jsoneditoronline.org/) — edit visually, then copy minified |
|
||||||
|
| **jq** | CLI | `cat config.json \| jq -c .` — outputs minified JSON |
|
||||||
|
| **Python** | CLI | `python3 -c "import json,sys;print(json.dumps(json.load(sys.stdin)))" < config.json` |
|
||||||
|
| **Node.js** | CLI | `node -e "process.stdin.on('data',d=>console.log(JSON.stringify(JSON.parse(d))))"< config.json` |
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Write your config in a pretty-printed JSON editor
|
||||||
|
2. Validate it (the tools above show errors)
|
||||||
|
3. Minify / compress to one line
|
||||||
|
4. Paste the single line as the `PICOCLAW_CONFIG_JSON` value in Coolify
|
||||||
|
|
||||||
|
**Example minified output:**
|
||||||
|
```
|
||||||
|
{"agents":{"defaults":{"provider":"gemini","model":"gemini-2.5-flash-lite","max_tokens":8192,"temperature":0.7,"max_tool_iterations":20,"workspace":"~/.picoclaw/workspace","restrict_to_workspace":true}},"providers":{"gemini":{"api_key":"AIzaSy..."},"vllm":{"api_key":"dummy","api_base":"http://ollama:11434/v1"}},"channels":{"telegram":{"enabled":true,"token":"123456:ABC-DEF...","allow_from":["123456789"]}},"tools":{"web":{"duckduckgo":{"enabled":true,"max_results":5}}},"heartbeat":{"enabled":true,"interval":30},"gateway":{"host":"0.0.0.0","port":18790},"devices":{"enabled":false,"monitor_usb":false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Method 2: Mounted Config File
|
||||||
|
|
||||||
|
**Best for:** Users who prefer editing a normal file, and want pretty-printed JSON without minification.
|
||||||
|
|
||||||
|
Use Coolify's **Storages** feature to mount a config file into the container:
|
||||||
|
|
||||||
|
#### Step-by-step:
|
||||||
|
|
||||||
|
1. Go to your PicoClaw service in Coolify
|
||||||
|
2. Click the **Storages** tab
|
||||||
|
3. Click **+ Add** and configure:
|
||||||
|
- **Source Path:** Leave empty (Coolify auto-creates it) or set to `/data/coolify/applications/<your-app-uuid>/config.json`
|
||||||
|
- **Destination Path:** `/config/config.json`
|
||||||
|
4. Save the storage mount
|
||||||
|
5. SSH into your Coolify server and create the config file:
|
||||||
|
```bash
|
||||||
|
# Find your app's data directory
|
||||||
|
ls /data/coolify/applications/
|
||||||
|
|
||||||
|
# Create the config file (replace <uuid> with your app's UUID)
|
||||||
|
nano /data/coolify/applications/<uuid>/config.json
|
||||||
|
```
|
||||||
|
6. Paste your full config JSON (pretty-printed is fine!) and save
|
||||||
|
7. **Restart** the service in Coolify (no rebuild needed)
|
||||||
|
|
||||||
|
The entrypoint will automatically detect `/config/config.json` and use it.
|
||||||
|
|
||||||
|
#### ✅ Advantages
|
||||||
|
- **Pretty-printed JSON** — no minification needed, easy to read and edit
|
||||||
|
- **Full control** — same flexibility as Method 1
|
||||||
|
- **Edit without rebuild** — just edit the file on disk and restart the container
|
||||||
|
- **Custom providers** — Ollama, vLLM, and any other custom provider work fine
|
||||||
|
|
||||||
|
#### ❌ Limitations
|
||||||
|
- **Requires SSH access** — you need SSH into the Coolify server to create/edit the file
|
||||||
|
- **No Coolify UI editing** — you can't edit the file content from Coolify's web UI (only set the mount path)
|
||||||
|
- **File must exist before starting** — if the file doesn't exist, this method is skipped and it falls through to Method 3
|
||||||
|
- **Not portable** — the config lives on the server's filesystem, not in Coolify's database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Method 3: Individual Environment Variables
|
||||||
|
|
||||||
|
**Best for:** Simple setups — just Gemini + one channel, defaults for everything else.
|
||||||
|
|
||||||
|
Set these in Coolify's **Environment Variables** tab:
|
||||||
|
|
||||||
|
#### Required
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PICOCLAW_PROVIDERS_GEMINI_API_KEY` | Gemini API key | `AIzaSy...` |
|
||||||
|
|
||||||
|
#### Provider Keys (optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PICOCLAW_PROVIDERS_OPENROUTER_API_KEY` | OpenRouter API key |
|
||||||
|
| `PICOCLAW_PROVIDERS_OPENAI_API_KEY` | OpenAI API key |
|
||||||
|
| `PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY` | Anthropic API key |
|
||||||
|
| `PICOCLAW_PROVIDERS_GROQ_API_KEY` | Groq API key |
|
||||||
|
| `PICOCLAW_PROVIDERS_MISTRAL_API_KEY` | Mistral API key |
|
||||||
|
| `PICOCLAW_PROVIDERS_DEEPSEEK_API_KEY` | DeepSeek API key |
|
||||||
|
|
||||||
|
#### Channel Config (optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PICOCLAW_CHANNELS_TELEGRAM_ENABLED` | `true` / `false` |
|
||||||
|
| `PICOCLAW_CHANNELS_TELEGRAM_TOKEN` | Telegram bot token |
|
||||||
|
| `PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM` | Comma-separated user IDs (e.g., `123,456`) |
|
||||||
|
| `PICOCLAW_CHANNELS_DISCORD_ENABLED` | `true` / `false` |
|
||||||
|
| `PICOCLAW_CHANNELS_DISCORD_TOKEN` | Discord bot token |
|
||||||
|
| `PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM` | Comma-separated user IDs |
|
||||||
|
|
||||||
|
#### Model Config (optional)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PICOCLAW_AGENTS_DEFAULTS_PROVIDER` | `gemini` | LLM provider name |
|
||||||
|
| `PICOCLAW_AGENTS_DEFAULTS_MODEL` | `gemini-2.5-flash-lite` | Model name |
|
||||||
|
| `PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS` | `8192` | Max output tokens |
|
||||||
|
| `PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE` | `0.7` | Temperature |
|
||||||
|
|
||||||
|
#### Other (optional)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `TZ` | `UTC` | Timezone (e.g., `Asia/Kolkata` for IST) |
|
||||||
|
| `PICOCLAW_HEARTBEAT_ENABLED` | `true` | Enable heartbeat |
|
||||||
|
| `PICOCLAW_HEARTBEAT_INTERVAL` | `30` | Heartbeat interval (minutes) |
|
||||||
|
| `PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED` | `true` | DuckDuckGo search |
|
||||||
|
| `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED` | `false` | Brave search |
|
||||||
|
| `PICOCLAW_TOOLS_WEB_BRAVE_API_KEY` | | Brave API key |
|
||||||
|
|
||||||
|
#### ✅ Advantages
|
||||||
|
- **Zero JSON knowledge needed** — just set key=value pairs
|
||||||
|
- **Easiest to set up** — add a few env vars and deploy
|
||||||
|
- **Good for quick testing** — get running in under a minute
|
||||||
|
|
||||||
|
#### ❌ Limitations
|
||||||
|
- **No custom providers** — only the built-in providers are supported (Gemini, OpenRouter, OpenAI, Anthropic, Groq, Mistral, DeepSeek, Zhipu, Moonshot, Nvidia, vLLM). You **cannot** add Ollama or other custom OpenAI-compatible providers
|
||||||
|
- **No custom API base URLs** — you can't override `api_base` for providers (needed for self-hosted models)
|
||||||
|
- **Limited channel support** — only Telegram, Discord, Slack, and LINE are configurable. Feishu, DingTalk, QQ, WhatsApp, MaixCam, and OneBot are **not** configurable via env vars
|
||||||
|
- **No proxy settings** — provider proxy configuration is not available
|
||||||
|
- **Hardcoded defaults** — many settings like `max_results`, `webhook_port`, etc. use hardcoded defaults that can't be changed
|
||||||
|
- **Allow-lists are comma-separated strings** — works but less flexible than JSON arrays (no spaces in IDs)
|
||||||
|
|
||||||
|
> **💡 Tip:** Start with Method 3 to get running quickly, then switch to Method 1 when you need more control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Running Agent & Doctor Commands
|
||||||
|
|
||||||
|
The gateway service runs automatically. To run one-shot commands, SSH into your Coolify server and use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run agent mode (one-shot question)
|
||||||
|
docker compose -f docker-compose-coolify.yml --profile agent run --rm picoclaw-agent -m "Hello!"
|
||||||
|
|
||||||
|
# Run agent mode (interactive)
|
||||||
|
docker compose -f docker-compose-coolify.yml --profile agent run --rm picoclaw-agent
|
||||||
|
|
||||||
|
# Run doctor (diagnostics)
|
||||||
|
docker compose -f docker-compose-coolify.yml --profile doctor run --rm picoclaw-doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎮 Chat Commands (Telegram/Discord)
|
||||||
|
|
||||||
|
You can check status and swap models directly from your chat app:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `/models` | View active model, provider, and all configured endpoints. |
|
||||||
|
| `/model <name>` | Switch the **model name** (keeping current provider). |
|
||||||
|
| `/model <provider>/<model>` | Switch **both** provider and model (e.g., `vllm/qwen3-coder-next:cloud`). |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Changes made via `/model` are active in memory. If the container restarts, it will revert to the default model defined in your `PICOCLAW_CONFIG_JSON` or environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## <20>️ Troubleshooting
|
||||||
|
|
||||||
|
### "No API key configured"
|
||||||
|
Your config isn't being loaded. Check:
|
||||||
|
- For Method 1: Is `PICOCLAW_CONFIG_JSON` set? Is it valid JSON?
|
||||||
|
- For Method 3: Is `PICOCLAW_PROVIDERS_GEMINI_API_KEY` set?
|
||||||
|
- Check container logs: `docker logs picoclaw-gateway` — look for the `📝 Using config from...` line.
|
||||||
|
|
||||||
|
### Container keeps restarting
|
||||||
|
Check logs: `docker logs picoclaw-gateway --tail 50`
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Invalid JSON in `PICOCLAW_CONFIG_JSON` (use a validator!)
|
||||||
|
- Missing API key for the configured provider
|
||||||
|
|
||||||
|
### Build fails
|
||||||
|
- Ensure you're using the `deploy/coolify` branch
|
||||||
|
- Check that both `Dockerfile.coolify` and `entrypoint-coolify.sh` exist in the repo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 File Structure (Coolify-specific)
|
||||||
|
|
||||||
|
```
|
||||||
|
picoclaw/
|
||||||
|
├── docker-compose-coolify.yml # Coolify-optimized compose file
|
||||||
|
├── Dockerfile.coolify # Coolify Dockerfile with entrypoint
|
||||||
|
├── entrypoint-coolify.sh # Config generator script
|
||||||
|
├── Dockerfile # Original Dockerfile (not used by Coolify)
|
||||||
|
├── docker-compose.yml # Original compose (not used by Coolify)
|
||||||
|
└── config/
|
||||||
|
└── config.example.json # Reference config with all options
|
||||||
|
```
|
||||||
43
Dockerfile.coolify
Normal file
43
Dockerfile.coolify
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# ============================================================
|
||||||
|
# PicoClaw Dockerfile — Coolify Edition
|
||||||
|
# Uses an entrypoint script that generates config.json from
|
||||||
|
# environment variables at container startup.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Stage 1: Build the picoclaw binary
|
||||||
|
FROM golang:1.26.0-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
# Stage 2: Minimal runtime image
|
||||||
|
FROM alpine:3.23
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
||||||
|
# Create picoclaw home directory
|
||||||
|
RUN /usr/local/bin/picoclaw onboard
|
||||||
|
|
||||||
|
# Copy the entrypoint script that generates config from env vars
|
||||||
|
COPY entrypoint-coolify.sh /usr/local/bin/entrypoint-coolify.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint-coolify.sh
|
||||||
|
|
||||||
|
# Default env vars (overridden by Coolify)
|
||||||
|
ENV PICOCLAW_AGENTS_DEFAULTS_PROVIDER="gemini"
|
||||||
|
ENV PICOCLAW_AGENTS_DEFAULTS_MODEL="gemini-2.5-flash-lite"
|
||||||
|
ENV PICOCLAW_PROVIDERS_GEMINI_API_KEY=""
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint-coolify.sh"]
|
||||||
|
CMD ["gateway"]
|
||||||
26
README.md
26
README.md
|
|
@ -199,6 +199,32 @@ docker compose --profile gateway build --no-cache
|
||||||
docker compose --profile gateway up -d
|
docker compose --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ☁️ Deploy on Coolify (3 Steps)
|
||||||
|
|
||||||
|
Deploy PicoClaw on [Coolify](https://coolify.io) in under 5 minutes:
|
||||||
|
|
||||||
|
**1. Create service** — In Coolify: **+ New** → **Docker Compose** → connect your fork of this repo
|
||||||
|
- Branch: `main`
|
||||||
|
- Compose file: `docker-compose-coolify.yml`
|
||||||
|
|
||||||
|
**2. Add env vars** — Go to **Environment Variables** tab and add:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| `PICOCLAW_PROVIDERS_GEMINI_API_KEY` | Your [Gemini API key](https://aistudio.google.com/apikey) |
|
||||||
|
| `PICOCLAW_CHANNELS_TELEGRAM_ENABLED` | `true` *(if using Telegram)* |
|
||||||
|
| `PICOCLAW_CHANNELS_TELEGRAM_TOKEN` | Your bot token from [@BotFather](https://t.me/BotFather) |
|
||||||
|
| `TZ` | `Asia/Kolkata` *(or your timezone)* |
|
||||||
|
|
||||||
|
**3. Deploy!** 🚀
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Need more control?** (custom providers like Ollama, allowlists, multiple channels)
|
||||||
|
> Set `PICOCLAW_CONFIG_JSON` with your full config as a single env var.
|
||||||
|
> See the complete guide: **[COOLIFY.md](COOLIFY.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
|
|
|
||||||
164
docker-compose-coolify.yml
Normal file
164
docker-compose-coolify.yml
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# ============================================================
|
||||||
|
# PicoClaw — Coolify-Optimized Docker Compose
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# DEPLOYMENT STEPS (Coolify):
|
||||||
|
# 1. Create a new service → Docker Compose
|
||||||
|
# 2. Point source to this repository
|
||||||
|
# 3. Set "Docker Compose file" path to: picoclaw/docker-compose-coolify.yml
|
||||||
|
# 4. Add environment variables in Coolify's UI (see below)
|
||||||
|
# 5. Deploy!
|
||||||
|
#
|
||||||
|
# REQUIRED ENVIRONMENT VARIABLES (set in Coolify UI):
|
||||||
|
# GEMINI_API_KEY — Your Gemini API key
|
||||||
|
#
|
||||||
|
# OPTIONAL ENVIRONMENT VARIABLES:
|
||||||
|
# LLM_PROVIDER — LLM provider (default: gemini)
|
||||||
|
# LLM_MODEL — Model name (default: gemini-2.5-flash-lite)
|
||||||
|
# TELEGRAM_BOT_TOKEN — Telegram bot token
|
||||||
|
# DISCORD_BOT_TOKEN — Discord bot token
|
||||||
|
# OPENROUTER_API_KEY — OpenRouter API key
|
||||||
|
# OPENAI_API_KEY — OpenAI API key
|
||||||
|
# ANTHROPIC_API_KEY — Anthropic API key
|
||||||
|
# GROQ_API_KEY — Groq API key (also enables voice transcription)
|
||||||
|
# BRAVE_SEARCH_API_KEY — Brave Search API key
|
||||||
|
# LINE_CHANNEL_SECRET — LINE channel secret
|
||||||
|
# LINE_CHANNEL_ACCESS_TOKEN — LINE channel access token
|
||||||
|
# TZ — Timezone (default: UTC)
|
||||||
|
#
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
|
# This is the main service that Coolify will auto-start
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-gateway:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.coolify
|
||||||
|
container_name: picoclaw-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Environment Variables ────────────────────
|
||||||
|
# If PICOCLAW_CONFIG_JSON is set, it takes full priority.
|
||||||
|
# Individual vars below are only used as fallback (Method 3).
|
||||||
|
environment:
|
||||||
|
# -- Full JSON config (Method 1 — overrides everything below) --
|
||||||
|
- PICOCLAW_CONFIG_JSON=${PICOCLAW_CONFIG_JSON:-}
|
||||||
|
|
||||||
|
# -- Core LLM Config (only used if PICOCLAW_CONFIG_JSON is empty) --
|
||||||
|
- LLM_PROVIDER=${LLM_PROVIDER:-}
|
||||||
|
- LLM_MODEL=${LLM_MODEL:-}
|
||||||
|
|
||||||
|
# -- Provider API Keys --
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||||
|
|
||||||
|
# -- Chat Channel Tokens --
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
|
||||||
|
- LINE_CHANNEL_SECRET=${LINE_CHANNEL_SECRET:-}
|
||||||
|
- LINE_CHANNEL_ACCESS_TOKEN=${LINE_CHANNEL_ACCESS_TOKEN:-}
|
||||||
|
|
||||||
|
# -- Web Search --
|
||||||
|
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-}
|
||||||
|
- BRAVE_SEARCH_ENABLED=${BRAVE_SEARCH_ENABLED:-}
|
||||||
|
- DUCKDUCKGO_ENABLED=${DUCKDUCKGO_ENABLED:-}
|
||||||
|
|
||||||
|
# -- Heartbeat --
|
||||||
|
- HEARTBEAT_ENABLED=${HEARTBEAT_ENABLED:-}
|
||||||
|
- HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-}
|
||||||
|
|
||||||
|
# -- Timezone --
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
|
||||||
|
# ── Volumes ──────────────────────────────────
|
||||||
|
volumes:
|
||||||
|
# Persistent workspace — sessions, memory, logs survive redeploys
|
||||||
|
# Note: config.json is created by 'picoclaw onboard' during Docker build
|
||||||
|
# and all settings are overridden via environment variables above
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
|
||||||
|
# ── Ports ────────────────────────────────────
|
||||||
|
# Expose ports if using LINE webhook or MaixCAM channel
|
||||||
|
# Coolify will auto-detect and configure these
|
||||||
|
ports:
|
||||||
|
- "18790:18790" # MaixCAM / Gateway
|
||||||
|
- "18791:18791" # LINE Webhook
|
||||||
|
|
||||||
|
# ── Health Check ─────────────────────────────
|
||||||
|
# Coolify uses this to determine if the container is healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "picoclaw", "status"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ── Command ──────────────────────────────────
|
||||||
|
command: ["gateway"]
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Agent (One-shot / Interactive)
|
||||||
|
# Not auto-started by Coolify (has profile).
|
||||||
|
# Run manually via SSH:
|
||||||
|
# docker compose -f docker-compose-coolify.yml run --rm picoclaw-agent -m "Hello"
|
||||||
|
# docker compose -f docker-compose-coolify.yml run --rm picoclaw-agent
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-agent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: picoclaw-agent
|
||||||
|
profiles:
|
||||||
|
- agent
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_PROVIDER=${LLM_PROVIDER:-gemini}
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_MODEL=${LLM_MODEL:-gemini-2.5-flash-lite}
|
||||||
|
- PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
|
- PICOCLAW_PROVIDERS_OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||||
|
- PICOCLAW_PROVIDERS_OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
volumes:
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
entrypoint: ["picoclaw", "agent"]
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Doctor (Diagnostic Tool)
|
||||||
|
# Not auto-started by Coolify (has profile).
|
||||||
|
# Run manually via SSH:
|
||||||
|
# docker compose -f docker-compose-coolify.yml run --rm picoclaw-doctor
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-doctor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: picoclaw-doctor
|
||||||
|
profiles:
|
||||||
|
- doctor
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_PROVIDER=${LLM_PROVIDER:-gemini}
|
||||||
|
- PICOCLAW_AGENTS_DEFAULTS_MODEL=${LLM_MODEL:-gemini-2.5-flash-lite}
|
||||||
|
- PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
volumes:
|
||||||
|
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||||
|
entrypoint: ["picoclaw", "doctor"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
picoclaw-workspace:
|
||||||
|
driver: local
|
||||||
153
entrypoint-coolify.sh
Normal file
153
entrypoint-coolify.sh
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# ============================================================
|
||||||
|
# PicoClaw Coolify Entrypoint
|
||||||
|
# Generates config.json before starting PicoClaw
|
||||||
|
#
|
||||||
|
# CONFIG PRIORITY (first match wins):
|
||||||
|
# 1. PICOCLAW_CONFIG_JSON env var — paste your entire JSON config
|
||||||
|
# 2. Mounted file at /config/config.json — use Coolify Storages
|
||||||
|
# 3. Auto-generated from individual env vars (basic setup)
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONFIG_DIR="/root/.picoclaw"
|
||||||
|
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
||||||
|
|
||||||
|
mkdir -p "${CONFIG_DIR}"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
# METHOD 1: Full JSON config via env var
|
||||||
|
# Set PICOCLAW_CONFIG_JSON in Coolify env vars
|
||||||
|
# with your entire config.json content
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
if [ -n "${PICOCLAW_CONFIG_JSON}" ]; then
|
||||||
|
echo "📝 Using config from PICOCLAW_CONFIG_JSON env var"
|
||||||
|
echo "${PICOCLAW_CONFIG_JSON}" > "${CONFIG_FILE}"
|
||||||
|
exec picoclaw "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
# METHOD 2: Mounted config file
|
||||||
|
# In Coolify → Storages → Add:
|
||||||
|
# Source: /data/coolify/applications/<uuid>/config.json
|
||||||
|
# Destination: /config/config.json
|
||||||
|
# Then paste your JSON in the file content
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
if [ -f "/config/config.json" ]; then
|
||||||
|
echo "📝 Using mounted config from /config/config.json"
|
||||||
|
cp /config/config.json "${CONFIG_FILE}"
|
||||||
|
exec picoclaw "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
# METHOD 3: Auto-generate from individual env vars
|
||||||
|
# Good for simple setups (Gemini + Telegram, etc.)
|
||||||
|
# ─────────────────────────────────────────────────
|
||||||
|
echo "📝 Generating config from individual env vars"
|
||||||
|
|
||||||
|
# Helper: comma-separated string → JSON array
|
||||||
|
csv_to_json_array() {
|
||||||
|
input="$1"
|
||||||
|
if [ -z "$input" ]; then echo "[]"; return; fi
|
||||||
|
result="["
|
||||||
|
first=true
|
||||||
|
OLD_IFS="$IFS"; IFS=","
|
||||||
|
for item in $input; do
|
||||||
|
item=$(echo "$item" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
if [ -n "$item" ]; then
|
||||||
|
if [ "$first" = true ]; then first=false; else result="${result},"; fi
|
||||||
|
result="${result}\"${item}\""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
echo "${result}]"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Resolve env vars ─────────────────────────────
|
||||||
|
# Accept both Coolify-friendly short names and PICOCLAW_* internal names
|
||||||
|
R_PROVIDER="${PICOCLAW_AGENTS_DEFAULTS_PROVIDER:-${LLM_PROVIDER:-gemini}}"
|
||||||
|
R_MODEL="${PICOCLAW_AGENTS_DEFAULTS_MODEL:-${LLM_MODEL:-gemini-2.5-flash-lite}}"
|
||||||
|
|
||||||
|
R_GEMINI_KEY="${PICOCLAW_PROVIDERS_GEMINI_API_KEY:-${GEMINI_API_KEY:-}}"
|
||||||
|
R_OPENROUTER_KEY="${PICOCLAW_PROVIDERS_OPENROUTER_API_KEY:-${OPENROUTER_API_KEY:-}}"
|
||||||
|
R_OPENAI_KEY="${PICOCLAW_PROVIDERS_OPENAI_API_KEY:-${OPENAI_API_KEY:-}}"
|
||||||
|
R_ANTHROPIC_KEY="${PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY:-}}"
|
||||||
|
R_GROQ_KEY="${PICOCLAW_PROVIDERS_GROQ_API_KEY:-${GROQ_API_KEY:-}}"
|
||||||
|
R_MISTRAL_KEY="${PICOCLAW_PROVIDERS_MISTRAL_API_KEY:-${MISTRAL_API_KEY:-}}"
|
||||||
|
R_DEEPSEEK_KEY="${PICOCLAW_PROVIDERS_DEEPSEEK_API_KEY:-${DEEPSEEK_API_KEY:-}}"
|
||||||
|
R_VLLM_KEY="${PICOCLAW_PROVIDERS_VLLM_API_KEY:-${VLLM_API_KEY:-}}"
|
||||||
|
R_VLLM_BASE="${PICOCLAW_PROVIDERS_VLLM_API_BASE:-${VLLM_API_BASE:-}}"
|
||||||
|
|
||||||
|
R_TELEGRAM="${PICOCLAW_CHANNELS_TELEGRAM_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}"
|
||||||
|
R_DISCORD="${PICOCLAW_CHANNELS_DISCORD_TOKEN:-${DISCORD_BOT_TOKEN:-}}"
|
||||||
|
R_LINE_SECRET="${PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET:-${LINE_CHANNEL_SECRET:-}}"
|
||||||
|
R_LINE_ACCESS="${PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN:-${LINE_CHANNEL_ACCESS_TOKEN:-}}"
|
||||||
|
|
||||||
|
R_BRAVE_KEY="${PICOCLAW_TOOLS_WEB_BRAVE_API_KEY:-${BRAVE_SEARCH_API_KEY:-}}"
|
||||||
|
R_BRAVE_ON="${PICOCLAW_TOOLS_WEB_BRAVE_ENABLED:-${BRAVE_SEARCH_ENABLED:-false}}"
|
||||||
|
R_DDG_ON="${PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED:-${DUCKDUCKGO_ENABLED:-true}}"
|
||||||
|
R_HB_ON="${PICOCLAW_HEARTBEAT_ENABLED:-${HEARTBEAT_ENABLED:-true}}"
|
||||||
|
R_HB_INT="${PICOCLAW_HEARTBEAT_INTERVAL:-${HEARTBEAT_INTERVAL:-30}}"
|
||||||
|
|
||||||
|
# ── Auto-enable channels when token is provided ──
|
||||||
|
if [ -n "$R_TELEGRAM" ]; then TG_ON="true"; else TG_ON="${PICOCLAW_CHANNELS_TELEGRAM_ENABLED:-false}"; fi
|
||||||
|
if [ -n "$R_DISCORD" ]; then DC_ON="true"; else DC_ON="${PICOCLAW_CHANNELS_DISCORD_ENABLED:-false}"; fi
|
||||||
|
if [ -n "$R_LINE_SECRET" ] && [ -n "$R_LINE_ACCESS" ]; then LN_ON="true"; else LN_ON="${PICOCLAW_CHANNELS_LINE_ENABLED:-false}"; fi
|
||||||
|
|
||||||
|
TELEGRAM_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM:-}")
|
||||||
|
DISCORD_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM:-}")
|
||||||
|
SLACK_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_SLACK_ALLOW_FROM:-}")
|
||||||
|
LINE_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_LINE_ALLOW_FROM:-}")
|
||||||
|
|
||||||
|
cat > "${CONFIG_FILE}" <<ENDOFCONFIG
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "~/.picoclaw/workspace",
|
||||||
|
"restrict_to_workspace": true,
|
||||||
|
"provider": "${R_PROVIDER}",
|
||||||
|
"model": "${R_MODEL}",
|
||||||
|
"max_tokens": ${PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS:-8192},
|
||||||
|
"temperature": ${PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE:-0.7},
|
||||||
|
"max_tool_iterations": ${PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS:-20}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"telegram": { "enabled": ${TG_ON}, "token": "${R_TELEGRAM}", "allow_from": ${TELEGRAM_ALLOW} },
|
||||||
|
"discord": { "enabled": ${DC_ON}, "token": "${R_DISCORD}", "allow_from": ${DISCORD_ALLOW} },
|
||||||
|
"slack": { "enabled": ${PICOCLAW_CHANNELS_SLACK_ENABLED:-false}, "bot_token": "${PICOCLAW_CHANNELS_SLACK_BOT_TOKEN:-}", "app_token": "${PICOCLAW_CHANNELS_SLACK_APP_TOKEN:-}", "allow_from": ${SLACK_ALLOW} },
|
||||||
|
"line": { "enabled": ${LN_ON}, "channel_secret": "${R_LINE_SECRET}", "channel_access_token": "${R_LINE_ACCESS}", "webhook_host": "0.0.0.0", "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": ${LINE_ALLOW} },
|
||||||
|
"maixcam": { "enabled": false, "host": "0.0.0.0", "port": 18790, "allow_from": [] },
|
||||||
|
"whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "allow_from": [] },
|
||||||
|
"feishu": { "enabled": false, "app_id": "", "app_secret": "", "encrypt_key": "", "verification_token": "", "allow_from": [] },
|
||||||
|
"dingtalk": { "enabled": false, "client_id": "", "client_secret": "", "allow_from": [] },
|
||||||
|
"onebot": { "enabled": false, "ws_url": "ws://127.0.0.1:3001", "access_token": "", "reconnect_interval": 5, "group_trigger_prefix": [], "allow_from": [] }
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"gemini": { "api_key": "${R_GEMINI_KEY}", "api_base": "${PICOCLAW_PROVIDERS_GEMINI_API_BASE:-}" },
|
||||||
|
"openrouter": { "api_key": "${R_OPENROUTER_KEY}", "api_base": "${PICOCLAW_PROVIDERS_OPENROUTER_API_BASE:-}" },
|
||||||
|
"openai": { "api_key": "${R_OPENAI_KEY}", "api_base": "${PICOCLAW_PROVIDERS_OPENAI_API_BASE:-}" },
|
||||||
|
"anthropic": { "api_key": "${R_ANTHROPIC_KEY}", "api_base": "${PICOCLAW_PROVIDERS_ANTHROPIC_API_BASE:-}" },
|
||||||
|
"groq": { "api_key": "${R_GROQ_KEY}", "api_base": "${PICOCLAW_PROVIDERS_GROQ_API_BASE:-}" },
|
||||||
|
"mistral": { "api_key": "${R_MISTRAL_KEY}", "api_base": "${PICOCLAW_PROVIDERS_MISTRAL_API_BASE:-}" },
|
||||||
|
"zhipu": { "api_key": "${PICOCLAW_PROVIDERS_ZHIPU_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_ZHIPU_API_BASE:-}" },
|
||||||
|
"moonshot": { "api_key": "${PICOCLAW_PROVIDERS_MOONSHOT_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_MOONSHOT_API_BASE:-}" },
|
||||||
|
"deepseek": { "api_key": "${R_DEEPSEEK_KEY}", "api_base": "${PICOCLAW_PROVIDERS_DEEPSEEK_API_BASE:-}" },
|
||||||
|
"nvidia": { "api_key": "${PICOCLAW_PROVIDERS_NVIDIA_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_NVIDIA_API_BASE:-}" },
|
||||||
|
"vllm": { "api_key": "${R_VLLM_KEY}", "api_base": "${R_VLLM_BASE}" }
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": { "enabled": ${R_BRAVE_ON}, "api_key": "${R_BRAVE_KEY}", "max_results": 5 },
|
||||||
|
"duckduckgo": { "enabled": ${R_DDG_ON}, "max_results": 5 }
|
||||||
|
},
|
||||||
|
"firecrawl": { "enabled": ${PICOCLAW_TOOLS_FIRECRAWL_ENABLED:-false}, "api_key": "${PICOCLAW_TOOLS_FIRECRAWL_API_KEY:-}", "api_base": "https://api.firecrawl.dev/v1" },
|
||||||
|
"serpapi": { "enabled": ${PICOCLAW_TOOLS_SERPAPI_ENABLED:-false}, "api_key": "${PICOCLAW_TOOLS_SERPAPI_API_KEY:-}", "max_results": 10 }
|
||||||
|
},
|
||||||
|
"heartbeat": { "enabled": ${R_HB_ON}, "interval": ${R_HB_INT} },
|
||||||
|
"devices": { "enabled": false, "monitor_usb": false },
|
||||||
|
"gateway": { "host": "0.0.0.0", "port": 18790 }
|
||||||
|
}
|
||||||
|
ENDOFCONFIG
|
||||||
|
|
||||||
|
exec picoclaw "$@"
|
||||||
|
|
@ -33,6 +33,7 @@ import (
|
||||||
type AgentLoop struct {
|
type AgentLoop struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
provider providers.LLMProvider
|
provider providers.LLMProvider
|
||||||
|
cfg *config.Config
|
||||||
workspace string
|
workspace string
|
||||||
model string
|
model string
|
||||||
contextWindow int // Maximum context window size in tokens
|
contextWindow int // Maximum context window size in tokens
|
||||||
|
|
@ -142,9 +143,10 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
return &AgentLoop{
|
return &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
cfg: cfg,
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
model: cfg.Agents.Defaults.Model,
|
model: cfg.Agents.Defaults.Model,
|
||||||
contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
|
contextWindow: cfg.Agents.Defaults.MaxTokens,
|
||||||
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
||||||
sessions: sessionsManager,
|
sessions: sessionsManager,
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
|
|
@ -200,6 +202,86 @@ func (al *AgentLoop) Stop() {
|
||||||
al.running.Store(false)
|
al.running.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) GetModel() string {
|
||||||
|
return al.model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) SetModel(model string) {
|
||||||
|
al.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
// listModelsResponse builds a dynamic /models response from the current config.
|
||||||
|
func (al *AgentLoop) listModelsResponse() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📋 **Model Info**\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("**Active model:** `%s`\n", al.model))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Active provider:** `%s`\n\n", al.cfg.Agents.Defaults.Provider))
|
||||||
|
|
||||||
|
// List all configured providers
|
||||||
|
type providerEntry struct {
|
||||||
|
Name string
|
||||||
|
APIBase string
|
||||||
|
}
|
||||||
|
providersList := []providerEntry{
|
||||||
|
{"gemini", al.cfg.Providers.Gemini.APIBase},
|
||||||
|
{"openrouter", al.cfg.Providers.OpenRouter.APIBase},
|
||||||
|
{"openai", al.cfg.Providers.OpenAI.APIBase},
|
||||||
|
{"anthropic", al.cfg.Providers.Anthropic.APIBase},
|
||||||
|
{"vllm", al.cfg.Providers.VLLM.APIBase},
|
||||||
|
{"groq", al.cfg.Providers.Groq.APIBase},
|
||||||
|
{"deepseek", al.cfg.Providers.DeepSeek.APIBase},
|
||||||
|
{"nvidia", al.cfg.Providers.Nvidia.APIBase},
|
||||||
|
{"moonshot", al.cfg.Providers.Moonshot.APIBase},
|
||||||
|
{"zhipu", al.cfg.Providers.Zhipu.APIBase},
|
||||||
|
}
|
||||||
|
|
||||||
|
hasConfigured := false
|
||||||
|
for _, p := range providersList {
|
||||||
|
// Show providers that have either an API key or API base configured
|
||||||
|
hasKey := false
|
||||||
|
switch p.Name {
|
||||||
|
case "gemini":
|
||||||
|
hasKey = al.cfg.Providers.Gemini.APIKey != ""
|
||||||
|
case "openrouter":
|
||||||
|
hasKey = al.cfg.Providers.OpenRouter.APIKey != ""
|
||||||
|
case "openai":
|
||||||
|
hasKey = al.cfg.Providers.OpenAI.APIKey != ""
|
||||||
|
case "anthropic":
|
||||||
|
hasKey = al.cfg.Providers.Anthropic.APIKey != ""
|
||||||
|
case "vllm":
|
||||||
|
hasKey = al.cfg.Providers.VLLM.APIKey != "" || al.cfg.Providers.VLLM.APIBase != ""
|
||||||
|
case "groq":
|
||||||
|
hasKey = al.cfg.Providers.Groq.APIKey != ""
|
||||||
|
case "deepseek":
|
||||||
|
hasKey = al.cfg.Providers.DeepSeek.APIKey != ""
|
||||||
|
case "nvidia":
|
||||||
|
hasKey = al.cfg.Providers.Nvidia.APIKey != ""
|
||||||
|
case "moonshot":
|
||||||
|
hasKey = al.cfg.Providers.Moonshot.APIKey != ""
|
||||||
|
case "zhipu":
|
||||||
|
hasKey = al.cfg.Providers.Zhipu.APIKey != ""
|
||||||
|
}
|
||||||
|
if hasKey {
|
||||||
|
if !hasConfigured {
|
||||||
|
sb.WriteString("**Configured providers:**\n")
|
||||||
|
hasConfigured = true
|
||||||
|
}
|
||||||
|
active := ""
|
||||||
|
if p.Name == al.cfg.Agents.Defaults.Provider {
|
||||||
|
active = " ✅"
|
||||||
|
}
|
||||||
|
if p.APIBase != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("- `%s` → %s%s\n", p.Name, p.APIBase, active))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("- `%s`%s\n", p.Name, active))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n_Usage: `/model <name>` or `/model <provider>/<model>`_")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
al.tools.Register(tool)
|
al.tools.Register(tool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,7 @@ type TelegramChannel struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
transcriber *voice.GroqTranscriber
|
transcriber *voice.GroqTranscriber
|
||||||
placeholders sync.Map // chatID -> messageID
|
stopThinking sync.Map // chatID -> typingCancel
|
||||||
stopThinking sync.Map // chatID -> thinkingCancel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type thinkingCancel struct {
|
type thinkingCancel struct {
|
||||||
|
|
@ -75,7 +74,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
transcriber: nil,
|
transcriber: nil,
|
||||||
placeholders: sync.Map{},
|
|
||||||
stopThinking: sync.Map{},
|
stopThinking: sync.Map{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +131,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||||
c.setRunning(false)
|
c.setRunning(false)
|
||||||
|
|
@ -149,7 +148,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return fmt.Errorf("invalid chat ID: %w", err)
|
return fmt.Errorf("invalid chat ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop thinking animation
|
// Stop typing indicator goroutine
|
||||||
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
||||||
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
||||||
cf.Cancel()
|
cf.Cancel()
|
||||||
|
|
@ -159,18 +158,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||||
|
|
||||||
// Try to edit placeholder
|
|
||||||
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
|
||||||
c.placeholders.Delete(msg.ChatID)
|
|
||||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
|
||||||
|
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Fallback to new message if edit fails
|
|
||||||
}
|
|
||||||
|
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
|
|
@ -321,31 +308,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thinking indicator
|
// Start repeating typing indicator (expires after 5s per Telegram API,
|
||||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
// so we re-send every 4s until the response arrives)
|
||||||
if err != nil {
|
|
||||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop any previous thinking animation
|
|
||||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||||
|
// Cancel any previous typing goroutine for this chat
|
||||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
||||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
||||||
cf.Cancel()
|
cf.Cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
typingCtx, typingCancel := context.WithCancel(ctx)
|
||||||
// Create cancel function for thinking state
|
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: typingCancel})
|
||||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
go func() {
|
||||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
ticker := time.NewTicker(4 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
// Send immediately first
|
||||||
if err == nil {
|
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||||
pID := pMsg.MessageID
|
for {
|
||||||
c.placeholders.Store(chatIDStr, pID)
|
select {
|
||||||
}
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue