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
1d748fb742
commit
86bb45610c
7 changed files with 895 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
|
|
@ -172,6 +172,32 @@ docker compose --profile gateway build --no-cache
|
|||
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
|
||||
|
||||
> [!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 "$@"
|
||||
|
|
@ -32,6 +32,7 @@ import (
|
|||
type AgentLoop struct {
|
||||
bus *bus.MessageBus
|
||||
provider providers.LLMProvider
|
||||
cfg *config.Config
|
||||
workspace string
|
||||
model string
|
||||
contextWindow int // Maximum context window size in tokens
|
||||
|
|
@ -137,9 +138,10 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
return &AgentLoop{
|
||||
bus: msgBus,
|
||||
provider: provider,
|
||||
cfg: cfg,
|
||||
workspace: workspace,
|
||||
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,
|
||||
sessions: sessionsManager,
|
||||
state: stateManager,
|
||||
|
|
@ -195,6 +197,89 @@ func (al *AgentLoop) Stop() {
|
|||
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},
|
||||
{"mistral", al.cfg.Providers.Mistral.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 "mistral":
|
||||
hasKey = al.cfg.Providers.Mistral.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) {
|
||||
al.tools.Register(tool)
|
||||
}
|
||||
|
|
@ -263,6 +348,45 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
return al.processSystemMessage(ctx, msg)
|
||||
}
|
||||
|
||||
// Handle Slash Commands
|
||||
if strings.HasPrefix(msg.Content, "/") {
|
||||
cmd := strings.Split(msg.Content, " ")[0]
|
||||
switch cmd {
|
||||
case "/model":
|
||||
parts := strings.SplitN(msg.Content, " ", 2)
|
||||
if len(parts) == 1 {
|
||||
return fmt.Sprintf("Current model: `%s`\nCurrent provider: `%s`\n\n_Usage: `/model <name>` or `/model <provider>/<model>`_", al.model, al.cfg.Agents.Defaults.Provider), nil
|
||||
}
|
||||
input := strings.TrimSpace(parts[1])
|
||||
|
||||
// Check for provider/model format (e.g. "vllm/qwen3-coder-next:cloud")
|
||||
if idx := strings.Index(input, "/"); idx > 0 {
|
||||
newProvider := input[:idx]
|
||||
newModel := input[idx+1:]
|
||||
oldProvider := al.cfg.Agents.Defaults.Provider
|
||||
oldModel := al.model
|
||||
|
||||
// Switch provider
|
||||
al.cfg.Agents.Defaults.Provider = newProvider
|
||||
newLLM, err := providers.CreateProvider(al.cfg)
|
||||
if err != nil {
|
||||
al.cfg.Agents.Defaults.Provider = oldProvider
|
||||
return fmt.Sprintf("❌ Failed to switch to `%s`: %v", newProvider, err), nil
|
||||
}
|
||||
al.provider = newLLM
|
||||
al.SetModel(newModel)
|
||||
return fmt.Sprintf("✅ Switched: `%s/%s` → `%s/%s`", oldProvider, oldModel, newProvider, newModel), nil
|
||||
}
|
||||
|
||||
// Model-only switch (keep current provider)
|
||||
oldModel := al.model
|
||||
al.SetModel(input)
|
||||
return fmt.Sprintf("🔄 Switched model: `%s` → `%s` (provider: `%s`)", oldModel, input, al.cfg.Agents.Defaults.Provider), nil
|
||||
case "/models":
|
||||
return al.listModelsResponse(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Process as user message
|
||||
return al.runAgentLoop(ctx, processOptions{
|
||||
SessionKey: msg.SessionKey,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ type TelegramChannel struct {
|
|||
config config.TelegramConfig
|
||||
chatIDs map[string]int64
|
||||
transcriber *voice.GroqTranscriber
|
||||
placeholders sync.Map // chatID -> messageID
|
||||
stopThinking sync.Map // chatID -> thinkingCancel
|
||||
stopThinking sync.Map // chatID -> typingCancel
|
||||
}
|
||||
|
||||
type thinkingCancel struct {
|
||||
|
|
@ -69,7 +68,6 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
|
|||
config: cfg,
|
||||
chatIDs: make(map[string]int64),
|
||||
transcriber: nil,
|
||||
placeholders: sync.Map{},
|
||||
stopThinking: sync.Map{},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -88,6 +86,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
return fmt.Errorf("failed to start long polling: %w", err)
|
||||
}
|
||||
|
||||
c.registerCommands(ctx)
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
|
||||
"username": c.bot.Username(),
|
||||
|
|
@ -113,6 +113,41 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) registerCommands(ctx context.Context) {
|
||||
commands := []telego.BotCommand{
|
||||
{
|
||||
Command: "model",
|
||||
Description: "View current model or switch using 'model <name>' or 'model <provider>/<model>'",
|
||||
},
|
||||
{
|
||||
Command: "models",
|
||||
Description: "List all available models and configured providers",
|
||||
},
|
||||
}
|
||||
|
||||
err := c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
||||
Commands: commands,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to set bot commands", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify commands
|
||||
registered, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})
|
||||
if err == nil {
|
||||
var cmdNames []string
|
||||
for _, cmd := range registered {
|
||||
cmdNames = append(cmdNames, "/"+cmd.Command)
|
||||
}
|
||||
logger.InfoCF("telegram", "Successfully registered bot commands", map[string]interface{}{
|
||||
"commands": strings.Join(cmdNames, ", "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||
c.setRunning(false)
|
||||
|
|
@ -129,7 +164,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) 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 cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
||||
cf.Cancel()
|
||||
|
|
@ -139,18 +174,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
|
||||
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.ParseMode = telego.ModeHTML
|
||||
|
||||
|
|
@ -304,31 +327,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Thinking indicator
|
||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
if err != nil {
|
||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Stop any previous thinking animation
|
||||
// Start repeating typing indicator (expires after 5s per Telegram API,
|
||||
// so we re-send every 4s until the response arrives)
|
||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||
// Cancel any previous typing goroutine for this chat
|
||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
||||
cf.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Create cancel function for thinking state
|
||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
||||
|
||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
||||
if err == nil {
|
||||
pID := pMsg.MessageID
|
||||
c.placeholders.Store(chatIDStr, pID)
|
||||
typingCtx, typingCancel := context.WithCancel(ctx)
|
||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: typingCancel})
|
||||
go func() {
|
||||
ticker := time.NewTicker(4 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Send immediately first
|
||||
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
for {
|
||||
select {
|
||||
case <-typingCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue