merge: resolve conflict with main in channel manager

Keep both streamActive map from streaming feature and channelHashes
map + initChannels signature change from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Mamaghani 2026-03-20 13:48:42 +01:00
commit ac68b6d53c
114 changed files with 11602 additions and 2749 deletions

View file

@ -56,7 +56,7 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@ -79,7 +79,7 @@ jobs:
run: git tag "${{ steps.version.outputs.version }}"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2

View file

@ -74,7 +74,7 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@ -94,7 +94,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>

249
README.id.md Normal file
View file

@ -0,0 +1,249 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
<h3>Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [English](README.md) | **Bahasa Indonesia**
</div>
---
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com). Ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
🦐 PicoClaw adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot), ditulis ulang sepenuhnya dalam Go melalui proses "self-bootstrapping" — di mana AI Agent itu sendiri yang memandu seluruh migrasi arsitektur dan optimasi kode.
⚡️ Berjalan di perangkat keras $10 dengan RAM <10MB: Hemat 99% memori dibanding OpenClaw dan 98% lebih murah dibanding Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **🚨 KEAMANAN & SALURAN RESMI**
>
> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**.
>
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
> * **Peringatan:** Banyak domain `.ai/.org/.com/.net/...` yang didaftarkan oleh pihak ketiga.
> * **Peringatan:** PicoClaw masih dalam tahap pengembangan awal dan mungkin memiliki masalah keamanan jaringan yang belum teratasi. Jangan deploy ke lingkungan produksi sebelum rilis v1.0.
> * **Catatan:** PicoClaw baru-baru ini menggabungkan banyak PR, yang mungkin mengakibatkan penggunaan memori lebih besar (1020MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil.
## 📢 Berita
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental gateway hot-reload, gerbang keamanan cron, dan 2 perbaikan keamanan. PicoClaw kini di **25K ⭐**!
2026-03-09 🎉 **v0.2.1 — Update terbesar!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, dan routing model.
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI.
2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.
<details>
<summary>Berita lama...</summary>
2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting.
2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan.
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!
</details>
## ✨ Fitur
🪶 **Super Ringan**: Penggunaan memori <10MB 99% lebih kecil dari fungsionalitas inti OpenClaw.*
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
⚡️ **Secepat Kilat**: Waktu startup 400X lebih cepat, boot dalam <1 detik bahkan di prosesor single core 0,6GHz.
🌍 **Portabilitas Sejati**: Satu binary mandiri untuk RISC-V, ARM, MIPS, dan x86, Satu Klik Langsung Jalan!
🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas agent.
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke agent — encoding base64 otomatis untuk LLM multimodal.
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
_*Versi terbaru mungkin menggunakan 1020MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._
| | OpenClaw | NanoBot | **PicoClaw** |
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Bahasa** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Startup**</br>(0,8GHz core) | >500d | >30d | **<1d** |
| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC </br>~$50 | **Semua Board Linux**</br>**Mulai dari $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
## 🦾 Demonstrasi
### 🛠️ Alur Kerja Asisten Standar
<table align="center">
<tr align="center">
<th><p align="center">🧩 Full-Stack Engineer</p></th>
<th><p align="center">🗂️ Pencatatan & Manajemen Perencanaan</p></th>
<th><p align="center">🔎 Pencarian Web & Pembelajaran</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Develop • Deploy • Scale</td>
<td align="center">Jadwal • Otomasi • Memori</td>
<td align="center">Penemuan • Wawasan • Tren</td>
</tr>
</table>
### 📱 Jalankan di HP Android Lama
Berikan kehidupan kedua untuk HP lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. Panduan Cepat:
1. **Instal [Termux](https://github.com/termux/termux-app)** (Unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play).
2. **Jalankan perintah**
```bash
# Unduh rilis terbaru dari https://github.com/sipeed/picoclaw/releases
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard
```
Kemudian ikuti instruksi di bagian "Panduan Cepat" untuk menyelesaikan konfigurasi!
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 Deploy Inovatif dengan Footprint Rendah
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk Home Assistant Minimal
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) untuk Pemeliharaan Server Otomatis
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) untuk Pemantauan Cerdas
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Lebih Banyak Kasus Deploy Menanti!
## 📦 Instalasi
### Instal dengan binary yang sudah dikompilasi
Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases).
### Instal dari source (fitur terbaru, disarankan untuk pengembangan)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Build, tidak perlu instal
make build
# Build untuk berbagai platform
make build-all
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Build dan Instal
make install
```
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
## 📚 Dokumentasi
Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat.
| Topik | Deskripsi |
|-------|-----------|
| 🐳 [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent, konfigurasi Panduan Cepat |
| 💬 [Aplikasi Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, dan lainnya |
| ⚙️ [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sumber skill, sandbox keamanan, heartbeat |
| 🔌 [Provider & Model](docs/providers.md) | 20+ provider LLM, routing model, konfigurasi model_list, arsitektur provider |
| 🔄 [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
| 🐛 [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
| 🔧 [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan tool, kebijakan exec |
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi.
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Referensi CLI
| Perintah | Deskripsi |
| ------------------------- | -------------------------------- |
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
| `picoclaw agent -m "..."` | Chat dengan agent |
| `picoclaw agent` | Mode chat interaktif |
| `picoclaw gateway` | Mulai gateway |
| `picoclaw status` | Tampilkan status |
| `picoclaw version` | Tampilkan info versi |
| `picoclaw cron list` | Daftar semua tugas terjadwal |
| `picoclaw cron add ...` | Tambah tugas terjadwal |
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
| `picoclaw cron remove` | Hapus tugas terjadwal |
| `picoclaw skills list` | Daftar skill yang terinstal |
| `picoclaw skills install` | Instal skill |
| `picoclaw migrate` | Migrasi data dari versi lama |
| `picoclaw auth login` | Autentikasi dengan provider |
### Tugas Terjadwal / Pengingat
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" → terpicu sekali setelah 10 menit
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" → terpicu setiap 2 jam
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" → menggunakan ekspresi cron
## 🤝 Kontribusi & Roadmap
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami.
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
Grup Pengguna:
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">

249
README.it.md Normal file
View file

@ -0,0 +1,249 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
<h3>Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾我们走</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>
---
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com). È scritto interamente in **Go** — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
🦐 PicoClaw è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot), riscritto da zero in Go attraverso un processo di auto-bootstrapping, in cui l'agente IA stesso ha guidato l'intera migrazione architetturale e l'ottimizzazione del codice.
⚡️ Funziona su hardware da $10 con meno di 10MB di RAM: il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **🚨 SICUREZZA & CANALI UFFICIALI**
>
> * **NESSUNA CRYPTO:** PicoClaw non ha **NESSUN** token/coin ufficiale. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **TRUFFA**.
>
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**.
> * **Attenzione:** Molti domini `.ai/.org/.com/.net/...` sono registrati da terze parti.
> * **Attenzione:** PicoClaw è in fase di sviluppo iniziale e potrebbe avere problemi di sicurezza di rete non risolti. Non distribuire in ambienti di produzione prima della release v1.0.
> * **Nota:** PicoClaw ha recentemente unito molte PR, il che potrebbe comportare un'impronta di memoria maggiore (1020MB) nelle ultime versioni. Prevediamo di dare priorità all'ottimizzazione delle risorse non appena il set di funzionalità corrente raggiungerà uno stato stabile.
## 📢 Novità
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), tracciamento dello stato dei sub-agent (`spawn_status`), hot-reload sperimentale del gateway, gate di sicurezza per cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K ⭐**!
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline di visione, store di memoria JSONL e routing dei modelli.
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e launcher Web UI.
2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.
<details>
<summary>Notizie precedenti...</summary>
2026-02-16 🎉 PicoClaw ha raggiunto 12K stelle in una settimana! Ruoli di maintainer della community e [roadmap](ROADMAP.md) pubblicati ufficialmente.
2026-02-13 🎉 PicoClaw ha raggiunto 5000 stelle in 4 giorni! Roadmap del progetto e gruppo sviluppatori in fase di avvio.
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli agenti IA su hardware da $10 con <10MB di RAM. 🦐 PicoClaw, andiamo!
</details>
## ✨ Caratteristiche
🪶 **Ultra-Leggero**: Impronta di memoria <10MB il 99% più piccolo delle funzionalità principali di OpenClaw.*
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
⚡️ **Avvio Fulmineo**: Tempo di avvio 400 volte più veloce, boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
🌍 **Vera Portabilità**: Singolo binario autonomo per RISC-V, ARM, MIPS e x86. Un click e si parte!
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo.
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent.
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'agent — codifica base64 automatica per LLM multimodali.
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
_*Le versioni recenti potrebbero usare 1020MB a causa delle fusioni rapide di funzionalità. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
| | OpenClaw | NanoBot | **PicoClaw** |
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
| **Linguaggio** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux </br>~$50 | **Qualsiasi scheda Linux**</br>**A partire da $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
## 🦾 Dimostrazione
### 🛠️ Flussi di Lavoro Standard dell'Assistente
<table align="center">
<tr align="center">
<th><p align="center">🧩 Ingegnere Full-Stack</p></th>
<th><p align="center">🗂️ Gestione Log & Pianificazione</p></th>
<th><p align="center">🔎 Ricerca Web & Apprendimento</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Sviluppa • Distribuisci • Scala</td>
<td align="center">Pianifica • Automatizza • Memorizza</td>
<td align="center">Scopri • Analizza • Tendenze</td>
</tr>
</table>
### 📱 Usa su vecchi telefoni Android
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. Avvio rapido:
1. **Installa [Termux](https://github.com/termux/termux-app)** (Scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play).
2. **Esegui i comandi**
```bash
# Scarica l'ultima release da https://github.com/sipeed/picoclaw/releases
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard
```
Poi segui le istruzioni nella sezione "Avvio Rapido" per completare la configurazione!
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 Deploy Innovativo a Bassa Impronta
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un Assistente Domotico Minimale
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) per la Manutenzione Automatizzata dei Server
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) per il Monitoraggio Intelligente
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Molti altri scenari di deploy ti aspettano!
## 📦 Installazione
### Installa con binario precompilato
Scarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases).
### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Compila, senza installare
make build
# Compila per più piattaforme
make build-all
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Compila e Installa
make install
```
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
## 📚 Documentazione
Per guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido.
| Argomento | Descrizione |
|-----------|-------------|
| 🐳 [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent, configurazione rapida |
| 💬 [App di Chat](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e altro |
| ⚙️ [Configurazione](docs/it/configuration.md) | Variabili d'ambiente, struttura del workspace, sorgenti delle skill, sandbox di sicurezza, heartbeat |
| 🔌 [Provider & Modelli](docs/providers.md) | 20+ provider LLM, routing dei modelli, configurazione model_list, architettura dei provider |
| 🔄 [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
| 🐛 [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
| 🔧 [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec |
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Riferimento CLI
| Comando | Descrizione |
| ------------------------- | ---------------------------------- |
| `picoclaw onboard` | Inizializza config & workspace |
| `picoclaw agent -m "..."` | Chatta con l'agent |
| `picoclaw agent` | Modalità chat interattiva |
| `picoclaw gateway` | Avvia il gateway |
| `picoclaw status` | Mostra lo stato |
| `picoclaw version` | Mostra le info sulla versione |
| `picoclaw cron list` | Elenca tutti i job pianificati |
| `picoclaw cron add ...` | Aggiunge un job pianificato |
| `picoclaw cron disable` | Disabilita un job pianificato |
| `picoclaw cron remove` | Rimuove un job pianificato |
| `picoclaw skills list` | Elenca le skill installate |
| `picoclaw skills install` | Installa una skill |
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
| `picoclaw auth login` | Autenticazione con i provider |
### Task Pianificati / Promemoria
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
* **Promemoria una tantum**: "Ricordami tra 10 minuti" → si attiva una volta dopo 10 min
* **Task ricorrenti**: "Ricordami ogni 2 ore" → si attiva ogni 2 ore
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" → usa un'espressione cron
## 🤝 Contribuisci & Roadmap
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completa.
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
Gruppi utenti:
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>

515
README.md
View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
</div>
@ -191,15 +191,510 @@ make install
For detailed guides, see the docs below. The README covers quick start only.
| Topic | Description |
|-------|-------------|
| 🐳 [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes, Quick Start configuration |
| 💬 [Chat Apps](docs/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom, and more |
| ⚙️ [Configuration](docs/configuration.md) | Environment variables, workspace layout, skill sources, security sandbox, heartbeat |
| 🔌 [Providers & Models](docs/providers.md) | 20+ LLM providers, model routing, model_list configuration, provider architecture |
| 🔄 [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
| 🐛 [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
| 🔧 [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies |
```bash
# 1. Clone this repo
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. First run — auto-generates docker/data/config.json then exits
docker compose -f docker/docker-compose.yml --profile gateway up
# The container prints "First-run setup complete." and stops.
# 3. Set your API keys
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
# 4. Start
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP]
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
```bash
# 5. Check logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 6. Stop
docker compose -f docker/docker-compose.yml --profile gateway down
```
### Launcher Mode (Web Console)
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
> [!WARNING]
> The web console does not yet support authentication. Avoid exposing it to the public internet.
### Agent Mode (One-shot)
```bash
# Ask a question
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# Interactive mode
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
### Update
```bash
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Quick Start
> [!TIP]
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
**1. Initialize**
```bash
picoclaw onboard
```
**2. Configure** (`~/.picoclaw/config.json`)
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "your-anthropic-key"
}
],
"tools": {
"web": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"tavily": {
"enabled": false,
"api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "YOUR_PERPLEXITY_API_KEY",
"max_results": 5
},
"searxng": {
"enabled": false,
"base_url": "http://your-searxng-instance:8888",
"max_results": 5
}
}
}
}
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **Web Search** (optional):
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
* DuckDuckGo - Built-in fallback (no API key required)
> **Note**: See `config.example.json` for a complete configuration template.
**4. Chat**
```bash
picoclaw agent -m "What is 2+2?"
```
That's it! You have a working AI assistant in 2 minutes.
---
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
| Channel | Setup |
| ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
| **WeCom AI Bot** | Medium (Token + AES key) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
**1. Create a bot**
* Open Telegram, search `@BotFather`
* Send `/newbot`, follow prompts
* Copy the token
**2. Configure**
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"]
}
}
}
```
> Get your user ID from `@userinfobot` on Telegram.
**3. Run**
```bash
picoclaw gateway
```
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
</details>
<details>
<summary><b>Discord</b></summary>
**1. Create a bot**
* Go to <https://discord.com/developers/applications>
* Create an application → Bot → Add Bot
* Copy the bot token
**2. Enable intents**
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
**4. Configure**
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"]
}
}
}
```
**5. Invite the bot**
* OAuth2 → URL Generator
* Scopes: `bot`
* Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server
**Optional: Group trigger mode**
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
```json
{
"channels": {
"discord": {
"group_trigger": { "mention_only": true }
}
}
}
```
You can also trigger by keyword prefixes (e.g. `!bot`):
```json
{
"channels": {
"discord": {
"group_trigger": { "prefixes": ["!bot"] }
}
}
}
```
**6. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
PicoClaw can connect to WhatsApp in two ways:
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
**Configure (native)**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"use_native": true,
"session_store_path": "",
"allow_from": []
}
}
}
```
If `session_store_path` is empty, the session is stored in `&lt;workspace&gt;/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
</details>
<details>
<summary><b>QQ</b></summary>
**1. Create a bot**
- Go to [QQ Open Platform](https://q.qq.com/#)
- Create an application → Get **AppID** and **AppSecret**
**2. Configure**
```json
{
"channels": {
"qq": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
**3. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>DingTalk</b></summary>
**1. Create a bot**
* Go to [Open Platform](https://open.dingtalk.com/)
* Create an internal app
* Copy Client ID and Client Secret
**2. Configure**
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
**3. Run**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>Matrix</b></summary>
**1. Prepare bot account**
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
* Create a bot user and obtain its access token
**2. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"allow_from": []
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
</details>
<details>
<summary><b>LINE</b></summary>
**1. Create a LINE Official Account**
- Go to [LINE Developers Console](https://developers.line.biz/)
- Create a provider → Create a Messaging API channel
- Copy **Channel Secret** and **Channel Access Token**
**2. Configure**
```json
{
"channels": {
"line": {
"enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_path": "/webhook/line",
"allow_from": []
}
}
}
```
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
**3. Set up Webhook URL**
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
```bash
# Example with ngrok (gateway default port is 18790)
ngrok http 18790
```
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
**4. Run**
```bash
picoclaw gateway
```
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
</details>
<details>
<summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports three types of WeCom integration:
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
**Quick Setup - WeCom AI Bot:**
**1. Create an AI Bot**
* Go to WeCom Admin Console → AI Bot
* Create a new AI Bot → Set name, avatar, etc.
* Copy **Bot ID** and **Secret**
**2. Configure**
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>

View file

@ -18,7 +18,7 @@
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [English](README.md)
</div>

View file

@ -0,0 +1,236 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// DefaultConfigPath returns the default path to the tui.toml config file.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", "tui.toml")
}
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
type TUIConfig struct {
Version string `toml:"version"`
Model Model `toml:"model"`
Provider Provider `toml:"provider"`
}
type Model struct {
Type string `toml:"type"` // "provider" (default) | "manual"
}
type Provider struct {
Schemes []Scheme `toml:"schemes"`
Users []User `toml:"users"`
Current ProviderCurrent `toml:"current"`
}
type Scheme struct {
Name string `toml:"name"` // unique key
BaseURL string `toml:"baseURL"` // required
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
}
type User struct {
Name string `toml:"name"`
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
Type string `toml:"type"` // "key" (default) | "OAuth"
Key string `toml:"key"`
}
type ProviderCurrent struct {
Scheme string `toml:"scheme"` // references Scheme.Name
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
Model string `toml:"model"` // from GET <baseURL>/models
}
// DefaultConfig returns a minimal valid TUIConfig.
func DefaultConfig() *TUIConfig {
return &TUIConfig{
Version: "1.0",
Model: Model{Type: "provider"},
Provider: Provider{
Schemes: []Scheme{},
Users: []User{},
Current: ProviderCurrent{},
},
}
}
// Load reads the TUI config from path. Returns a default config if the file does not exist.
func Load(path string) (*TUIConfig, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
if err != nil {
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
}
cfg := DefaultConfig()
if _, err := toml.Decode(string(data), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
applyDefaults(cfg)
return cfg, nil
}
// Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write config file %q: %w", path, err)
}
return nil
}
func applyDefaults(cfg *TUIConfig) {
if cfg.Version == "" {
cfg.Version = "1.0"
}
if cfg.Model.Type == "" {
cfg.Model.Type = "provider"
}
for i := range cfg.Provider.Schemes {
if cfg.Provider.Schemes[i].Type == "" {
cfg.Provider.Schemes[i].Type = "openai-compatible"
}
}
for i := range cfg.Provider.Users {
if cfg.Provider.Users[i].Type == "" {
cfg.Provider.Users[i].Type = "key"
}
}
}
// SchemeByName returns the first Scheme whose Name matches, or nil.
func (p *Provider) SchemeByName(name string) *Scheme {
for i := range p.Schemes {
if p.Schemes[i].Name == name {
return &p.Schemes[i]
}
}
return nil
}
// UsersForScheme returns all users whose Scheme field matches schemeName.
func (p *Provider) UsersForScheme(schemeName string) []User {
var out []User
for _, u := range p.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
// Preserves all other existing fields in the config file unchanged.
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
cfg["agents"] = make(map[string]any)
}
agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
agents["defaults"] = make(map[string]any)
}
defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
modelList := []any{}
if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
break
}
}
}
if !found {
modelList = append(modelList, tuiModel)
}
cfg["model_list"] = modelList
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
return err
}
return os.WriteFile(mainConfigPath, data, 0o600)
}
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
return "(not configured)"
}
label := cur.Scheme
if label != "" {
label += " / "
}
return label + cur.Model
}

View file

@ -1,49 +0,0 @@
package configstore
import (
"errors"
"os"
"path/filepath"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
const (
configDirName = ".picoclaw"
configFileName = "config.json"
)
func ConfigPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, configFileName), nil
}
func ConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, configDirName), nil
}
func Load() (*picoclawconfig.Config, error) {
path, err := ConfigPath()
if err != nil {
return nil, err
}
return picoclawconfig.LoadConfig(path)
}
func Save(cfg *picoclawconfig.Config) error {
if cfg == nil {
return errors.New("config is nil")
}
path, err := ConfigPath()
if err != nil {
return err
}
return picoclawconfig.SaveConfig(path, cfg)
}

View file

@ -1,522 +0,0 @@
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
type appState struct {
app *tview.Application
pages *tview.Pages
stack []string
config *picoclawconfig.Config
configPath string
gatewayCmd *exec.Cmd
menus map[string]*Menu
original []byte
hasOriginal bool
backupPath string
dirty bool
logPath string
}
func Run() error {
applyStyles()
cfg, err := configstore.Load()
if err != nil {
return err
}
path, err := configstore.ConfigPath()
if err != nil {
return err
}
if cfg == nil {
cfg = picoclawconfig.DefaultConfig()
}
originalData, hasOriginal := loadOriginalConfig(path)
backupPath := path + ".bak"
if hasOriginal {
_ = writeBackupConfig(backupPath, originalData)
}
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
state := &appState{
app: tview.NewApplication(),
pages: tview.NewPages(),
config: cfg,
configPath: path,
menus: map[string]*Menu{},
original: originalData,
hasOriginal: hasOriginal,
backupPath: backupPath,
logPath: logPath,
}
state.push("main", state.mainMenu())
root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true)
root.AddItem(footerView(), 1, 0, false)
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}
func (s *appState) push(name string, primitive tview.Primitive) {
s.pages.AddPage(name, primitive, true, true)
s.stack = append(s.stack, name)
s.pages.SwitchToPage(name)
if menu, ok := primitive.(*Menu); ok {
s.menus[name] = menu
}
}
func (s *appState) pop() {
if len(s.stack) == 0 {
return
}
last := s.stack[len(s.stack)-1]
s.pages.RemovePage(last)
s.stack = s.stack[:len(s.stack)-1]
if len(s.stack) == 0 {
s.app.Stop()
return
}
current := s.stack[len(s.stack)-1]
s.pages.SwitchToPage(current)
if menu, ok := s.menus[current]; ok {
s.refreshMenu(current, menu)
}
}
func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEsc:
s.requestExit()
return nil
}
return event
})
return menu
}
func (s *appState) refreshMenu(name string, menu *Menu) {
switch name {
case "main":
refreshMainMenu(menu, s)
case "model":
refreshModelMenuFromState(menu, s)
case "channel":
refreshChannelMenuFromState(menu, s)
}
}
func (s *appState) countChannels() (enabled int, total int) {
c := s.config.Channels
entries := []bool{
c.Telegram.Enabled,
c.Discord.Enabled,
c.QQ.Enabled,
c.MaixCam.Enabled,
c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled,
c.Slack.Enabled,
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
if v {
enabled++
}
}
return enabled, total
}
func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s)
}
}
func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName()
modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway"
gatewayDescription := "Launch gateway for channels"
if gatewayRunning {
gatewayLabel = "Stop Gateway"
gatewayDescription = "Gateway running"
}
items := []MenuItem{
{
Label: rootModelLabel(selectedModel),
Description: rootModelDescription(),
Action: func() {
s.push("model", s.modelMenu())
},
MainColor: func() *tcell.Color {
if modelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: rootChannelLabel(channelReady),
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() {
s.push("channel", s.channelMenu())
},
MainColor: func() *tcell.Color {
if channelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: "Start Talk",
Description: "Open picoclaw agent in terminal",
Action: func() {
s.requestStartTalk()
},
Disabled: !modelReady,
},
{
Label: gatewayLabel,
Description: gatewayDescription,
Action: func() {
if gatewayRunning {
s.stopGateway()
} else {
s.requestStartGateway()
}
refreshMainMenu(menu, s)
},
Disabled: !gatewayRunning && (!modelReady || !channelReady),
},
{
Label: "View Gateway Log",
Description: "Open gateway.log",
Action: func() {
s.viewGatewayLog()
},
},
{
Label: "Exit",
Description: "Exit the TUI",
Action: func() {
s.requestExit()
},
},
}
menu.applyItems(items)
}
func (s *appState) applyChangesValidated() bool {
if err := s.config.ValidateModelList(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := s.validateAgentModel(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := configstore.Save(s.config); err != nil {
s.showMessage("Save failed", err.Error())
return false
}
if data, err := os.ReadFile(s.configPath); err == nil {
s.original = data
s.hasOriginal = true
_ = writeBackupConfig(s.backupPath, data)
}
return true
}
func (s *appState) requestExit() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.app.Stop()
}, func() {
s.discardChanges()
s.app.Stop()
})
return
}
s.app.Stop()
}
func (s *appState) requestStartTalk() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startTalk()
}, func() {
s.startTalk()
})
return
}
s.startTalk()
}
func (s *appState) requestStartGateway() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startGateway()
}, func() {
s.startGateway()
})
return
}
s.startGateway()
}
func (s *appState) viewGatewayLog() {
data, err := os.ReadFile(s.logPath)
if err != nil {
s.showMessage("Log not found", "gateway.log not found")
return
}
text := tview.NewTextView()
text.SetBorder(true).SetTitle("Gateway Log")
text.SetText(string(data))
text.SetDoneFunc(func(key tcell.Key) {
s.pages.RemovePage("log")
})
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pages.RemovePage("log")
return nil
}
return event
})
s.pages.AddPage("log", text, true, true)
}
func (s *appState) selectedModelName() string {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return ""
}
if !s.isActiveModelValid() {
return ""
}
return modelName
}
func rootModelLabel(selected string) string {
if selected == "" {
return "Model (None)"
}
return "Model (" + selected + ")"
}
func rootModelDescription() string {
return "Using SPACE to choose your model"
}
func rootChannelLabel(valid bool) string {
if !valid {
return "Channel (no channel enabled)"
}
return "Channel"
}
func (s *appState) startTalk() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk")
return
}
if !s.applyChangesValidated() {
return
}
s.app.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
}
func (s *appState) startGateway() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting gateway")
return
}
if !s.hasEnabledChannel() {
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
return
}
if !s.applyChangesValidated() {
return
}
_ = stopGatewayProcess()
cmd := exec.Command("picoclaw", "gateway")
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
s.showMessage("Gateway failed", err.Error())
return
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
s.showMessage("Gateway failed", err.Error())
_ = logFile.Close()
return
}
_ = logFile.Close()
s.gatewayCmd = cmd
}
func (s *appState) stopGateway() {
_ = stopGatewayProcess()
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
_ = s.gatewayCmd.Process.Kill()
}
s.gatewayCmd = nil
}
func (s *appState) isGatewayRunning() bool {
return isGatewayProcessRunning()
}
func (s *appState) validateAgentModel() error {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return nil
}
_, err := s.config.GetModelConfig(modelName)
return err
}
func (s *appState) isActiveModelValid() bool {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return false
}
cfg, err := s.config.GetModelConfig(modelName)
if err != nil {
return false
}
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(cfg.Model) != ""
return hasKey && hasModel
}
func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
}
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
if s.pages.HasPage("apply") {
return
}
modal := tview.NewModal().
SetText("Apply changes or discard before continuing?").
AddButtons([]string{"Cancel", "Discard", "Apply"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage("apply")
switch buttonLabel {
case "Discard":
s.discardChanges()
if onDiscard != nil {
onDiscard()
}
case "Apply":
if s.applyChangesValidated() {
s.dirty = false
if onApply != nil {
onApply()
}
}
}
})
modal.SetBorder(true)
s.pages.AddPage("apply", modal, true, true)
}
func (s *appState) discardChanges() {
if s.hasOriginal {
_ = writeOriginalConfig(s.configPath, s.original)
} else {
_ = os.Remove(s.configPath)
}
_ = os.Remove(s.backupPath)
if cfg, err := configstore.Load(); err == nil && cfg != nil {
s.config = cfg
}
s.dirty = false
refreshMainMenuIfPresent(s)
}
func (s *appState) showMessage(title, message string) {
if s.pages.HasPage("message") {
return
}
modal := tview.NewModal().
SetText(strings.TrimSpace(message)).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
s.pages.RemovePage("message")
})
modal.SetTitle(title).SetBorder(true)
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
modal.SetTextColor(tview.Styles.PrimaryTextColor)
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
s.pages.AddPage("message", modal, true, true)
}
func loadOriginalConfig(path string) ([]byte, bool) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false
}
return nil, false
}
return data, true
}
func writeOriginalConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
func writeBackupConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}

View file

@ -1,433 +0,0 @@
package ui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{
channelItem(
"Telegram",
"Telegram bot settings",
s.config.Channels.Telegram.Enabled,
func() { s.push("channel-telegram", s.telegramForm()) },
),
channelItem(
"Discord",
"Discord bot settings",
s.config.Channels.Discord.Enabled,
func() { s.push("channel-discord", s.discordForm()) },
),
channelItem(
"QQ",
"QQ bot settings",
s.config.Channels.QQ.Enabled,
func() { s.push("channel-qq", s.qqForm()) },
),
channelItem(
"MaixCam",
"MaixCam gateway",
s.config.Channels.MaixCam.Enabled,
func() { s.push("channel-maixcam", s.maixcamForm()) },
),
channelItem(
"WhatsApp",
"WhatsApp bridge",
s.config.Channels.WhatsApp.Enabled,
func() { s.push("channel-whatsapp", s.whatsappForm()) },
),
channelItem(
"Feishu",
"Feishu bot settings",
s.config.Channels.Feishu.Enabled,
func() { s.push("channel-feishu", s.feishuForm()) },
),
channelItem(
"DingTalk",
"DingTalk bot settings",
s.config.Channels.DingTalk.Enabled,
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
),
channelItem(
"Slack",
"Slack bot settings",
s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) },
),
channelItem(
"Matrix",
"Matrix bot settings",
s.config.Channels.Matrix.Enabled,
func() { s.push("channel-matrix", s.matrixForm()) },
),
channelItem(
"LINE",
"LINE bot settings",
s.config.Channels.LINE.Enabled,
func() { s.push("channel-line", s.lineForm()) },
),
channelItem(
"OneBot",
"OneBot settings",
s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) },
),
channelItem(
"WeCom",
"WeCom bot settings",
s.config.Channels.WeCom.Enabled,
func() { s.push("channel-wecom", s.wecomForm()) },
),
channelItem(
"WeCom App",
"WeCom App settings",
s.config.Channels.WeComApp.Enabled,
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
}
}
func (s *appState) channelMenu() tview.Primitive {
menu := NewMenu("Channels", s.buildChannelMenuItems())
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return menu
}
func refreshChannelMenuFromState(menu *Menu, s *appState) {
menu.applyItems(s.buildChannelMenuItems())
}
func (s *appState) telegramForm() tview.Primitive {
cfg := &s.config.Channels.Telegram
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
cfg.Proxy = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) discordForm() tview.Primitive {
cfg := &s.config.Channels.Discord
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
cfg.MentionOnly = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) qqForm() tview.Primitive {
cfg := &s.config.Channels.QQ
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) maixcamForm() tview.Primitive {
cfg := &s.config.Channels.MaixCam
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
cfg.Host = strings.TrimSpace(text)
})
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) whatsappForm() tview.Primitive {
cfg := &s.config.Channels.WhatsApp
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
cfg.BridgeURL = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) feishuForm() tview.Primitive {
cfg := &s.config.Channels.Feishu
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
cfg.EncryptKey = strings.TrimSpace(text)
})
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
cfg.VerificationToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) dingtalkForm() tview.Primitive {
cfg := &s.config.Channels.DingTalk
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
cfg.ClientID = strings.TrimSpace(text)
})
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
cfg.ClientSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) slackForm() tview.Primitive {
cfg := &s.config.Channels.Slack
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
cfg.BotToken = strings.TrimSpace(text)
})
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
cfg.AppToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) lineForm() tview.Primitive {
cfg := &s.config.Channels.LINE
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
cfg.ChannelSecret = strings.TrimSpace(text)
})
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
cfg.ChannelAccessToken = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) matrixForm() tview.Primitive {
cfg := &s.config.Channels.Matrix
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
cfg.Homeserver = strings.TrimSpace(text)
})
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
cfg.UserID = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
cfg.DeviceID = strings.TrimSpace(text)
})
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
cfg.JoinOnInvite = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
cfg.WSUrl = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
addIntField(
form,
"Reconnect Interval",
cfg.ReconnectInterval,
func(value int) { cfg.ReconnectInterval = value },
)
form.AddInputField(
"Group Trigger Prefix",
strings.Join(cfg.GroupTriggerPrefix, ","),
128,
nil,
func(text string) {
cfg.GroupTriggerPrefix = splitCSV(text)
},
)
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) wecomForm() tview.Primitive {
cfg := &s.config.Channels.WeCom
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
cfg.WebhookURL = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
cfg.CorpID = strings.TrimSpace(text)
})
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
cfg.CorpSecret = strings.TrimSpace(text)
})
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
return func(v bool) {
*enabledPtr = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
}
}
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
*allowFrom = splitCSV(text)
})
}
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
form.AddCheckbox("Enabled", enabled, func(checked bool) {
onEnabled(checked)
})
return form
}
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
parts := strings.Split(strings.TrimSpace(input), ",")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
cleaned = append(cleaned, value)
}
return cleaned
}
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int64
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
item := MenuItem{
Label: label,
Description: description,
Action: action,
}
if !enabled {
color := tcell.ColorGray
item.MainColor = &color
}
return item
}

View file

@ -1,16 +0,0 @@
//go:build !windows
// +build !windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run()
}

View file

@ -1,16 +0,0 @@
//go:build windows
// +build windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
return cmd.Run()
}

View file

@ -1,72 +0,0 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type MenuAction func()
type MenuItem struct {
Label string
Description string
Action MenuAction
Disabled bool
MainColor *tcell.Color
DescColor *tcell.Color
}
type Menu struct {
*tview.Table
items []MenuItem
}
func NewMenu(title string, items []MenuItem) *Menu {
table := tview.NewTable().SetSelectable(true, false)
table.SetBorder(true).SetTitle(title)
table.SetBorders(false)
menu := &Menu{Table: table, items: items}
menu.applyItems(items)
menu.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(menu.items) {
return
}
item := menu.items[row]
if item.Disabled || item.Action == nil {
return
}
item.Action()
})
menu.SetSelectedStyle(
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
Background(tcell.NewRGBColor(189, 147, 249)),
)
return menu
}
func (m *Menu) applyItems(items []MenuItem) {
m.items = items
m.Clear()
for row, item := range items {
label := item.Label
if item.Disabled && label != "" {
label = label + " (disabled)"
}
left := tview.NewTableCell(label)
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
if item.MainColor != nil {
left.SetTextColor(*item.MainColor)
}
if item.DescColor != nil {
right.SetTextColor(*item.DescColor)
} else {
right.SetTextColor(tview.Styles.TertiaryTextColor)
}
if item.Disabled {
left.SetTextColor(tcell.ColorGray)
right.SetTextColor(tcell.ColorGray)
}
m.SetCell(row, 0, left)
m.SetCell(row, 1, right)
}
}

View file

@ -1,399 +0,0 @@
package ui
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
// Add model entry appended at the end so the models map to rows 1..N
items = append(items,
MenuItem{
Label: "**Add model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
menu := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
if event.Rune() == ' ' {
row, _ := menu.GetSelection()
if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row]
if !isModelValid(model) {
s.showMessage(
"Invalid model",
"Select a model with api_key or oauth auth_method",
)
return nil
}
s.config.Agents.Defaults.Model = model.ModelName
s.dirty = true
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
refreshMainMenuIfPresent(s)
}
return nil
}
return event
})
return menu
}
func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index]
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
addInput(form, "Model Name", model.ModelName, func(value string) {
if value == "" {
s.showMessage("Invalid model name", "Model Name cannot be empty")
return
}
if s.modelNameExists(value, index) {
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
return
}
oldName := model.ModelName
model.ModelName = value
if s.config.Agents.Defaults.Model == oldName {
s.config.Agents.Defaults.Model = value
}
s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Model", model.Model, func(value string) {
model.Model = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Base", model.APIBase, func(value string) {
model.APIBase = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Key", model.APIKey, func(value string) {
model.APIKey = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Proxy", model.Proxy, func(value string) {
model.Proxy = value
})
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
model.AuthMethod = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
model.ConnectMode = value
})
addInput(form, "Workspace", model.Workspace, func(value string) {
model.Workspace = value
})
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
model.MaxTokensField = value
})
addIntInput(form, "RPM", model.RPM, func(value int) {
model.RPM = value
})
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
model.RequestTimeout = value
})
form.AddButton("Delete", func() {
pageName := "confirm-delete-model"
if s.pages.HasPage(pageName) {
return
}
modal := tview.NewModal().
SetText("Are you sure you want to delete this model?").
AddButtons([]string{"Cancel", "Delete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage(pageName)
if buttonLabel == "Delete" {
s.deleteModel(index)
}
})
modal.SetTitle("Confirm Delete").SetBorder(true)
s.pages.AddPage(pageName, modal, true, true)
})
form.AddButton("Test", func() {
s.testModel(model)
})
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func addInput(form *tview.Form, label, value string, onChange func(string)) {
form.AddInputField(label, value, 128, nil, func(text string) {
onChange(strings.TrimSpace(text))
})
}
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
s.config.ModelList = append(s.config.ModelList, model)
}
func (s *appState) deleteModel(index int) {
if index < 0 || index >= len(s.config.ModelList) {
return
}
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
s.pop()
}
func modelStatusColor(valid bool, selected bool) *tcell.Color {
if valid {
color := tview.Styles.PrimaryTextColor
return &color
}
color := tcell.ColorGray
return &color
}
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models {
row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
cell := menu.GetCell(row, 0)
if cell != nil {
cell.SetText(label)
isSelected := model.ModelName == currentModel && currentModel != ""
color := modelStatusColor(isValid, isSelected)
if color != nil {
cell.SetTextColor(*color)
}
}
}
}
func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
items = append(items,
MenuItem{
Label: "**Add Model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items)
}
func isModelValid(model picoclawconfig.ModelConfig) bool {
hasKey := strings.TrimSpace(model.APIKey) != "" ||
strings.TrimSpace(model.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(model.Model) != ""
return hasKey && hasModel
}
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
target := strings.TrimSpace(name)
if target == "" {
return false
}
for i := range s.config.ModelList {
if i == excludeIndex {
continue
}
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
return true
}
}
return false
}
func (s *appState) nextAvailableModelName(base string) string {
name := strings.TrimSpace(base)
if name == "" {
name = "new-model"
}
if !s.modelNameExists(name, -1) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i)
if !s.modelNameExists(candidate, -1) {
return candidate
}
}
}
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil {
return
}
if strings.TrimSpace(model.APIKey) == "" {
s.showMessage("Missing API Key", "Set api_key before testing")
return
}
base := strings.TrimSpace(model.APIBase)
if base == "" {
s.showMessage("Missing API Base", "Set api_base before testing")
return
}
modelID := strings.TrimSpace(model.Model)
if modelID == "" {
s.showMessage("Missing Model", "Set model before testing")
return
}
if !strings.HasPrefix(modelID, "openai/") {
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
return
}
modelName := strings.TrimPrefix(modelID, "openai/")
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
payload := fmt.Sprintf(
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
modelName,
)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
resp, err := client.Do(request)
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.showMessage("Test OK", resp.Status)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
return
}
s.showMessage(
"Test failed",
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
)
}

View file

@ -1,55 +0,0 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
colorBlue = "[#3e5db9]"
colorRed = "[#d54646]"
banner = "\r\n[::b]" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"[:]"
)
func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
}
func bannerView() *tview.TextView {
text := tview.NewTextView()
text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
text.SetText(banner)
text.SetBorder(false)
return text
}
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
func footerView() *tview.TextView {
text := tview.NewTextView()
text.SetTextAlign(tview.AlignCenter)
text.SetText(footerText)
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
text.SetTextColor(tview.Styles.PrimaryTextColor)
text.SetBorder(false)
return text
}

View file

@ -1,15 +1,48 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
)
func main() {
if err := ui.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
configPath := tuicfg.DefaultConfigPath()
if len(os.Args) > 1 {
configPath = os.Args[1]
}
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
cmd := exec.Command("picoclaw", "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
app := ui.New(cfg, configPath)
// Bind model selection hook to sync to main config
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
}
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
}

View file

@ -0,0 +1,325 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
// OnModelSelected is called when a model is selected in the UI.
// Can be nil to disable.
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
}
// cacheKey returns the map key for a (scheme, user) pair.
func cacheKey(schemeName, userName string) string {
return fmt.Sprintf("%s/%s", schemeName, userName)
}
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
a.modelCacheMu.RLock()
defer a.modelCacheMu.RUnlock()
entries := a.modelCache[cacheKey(schemeName, userName)]
return append([]modelEntry(nil), entries...)
}
// refreshModelCache fetches models for every user in the config concurrently.
// Serialized by refreshMu so concurrent calls don't race on the cache map.
// When all fetches complete it calls onDone via QueueUpdateDraw.
func (a *App) refreshModelCache(onDone func()) {
go func() {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()
users := a.cfg.Provider.Users
schemes := a.cfg.Provider.Schemes
schemeURL := make(map[string]string, len(schemes))
for _, s := range schemes {
schemeURL[s.Name] = s.BaseURL
}
var wg sync.WaitGroup
for _, u := range users {
baseURL, ok := schemeURL[u.Scheme]
if !ok || baseURL == "" {
continue
}
if u.Key == "" {
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
a.modelCacheMu.Unlock()
continue
}
wg.Add(1)
bURL := baseURL
go func() {
defer wg.Done()
entries, err := fetchModels(bURL, u.Key)
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err != nil || len(entries) == 0 {
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
} else {
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
}
a.modelCacheMu.Unlock()
}()
}
wg.Wait()
if onDone != nil {
a.tapp.QueueUpdateDraw(onDone)
}
}()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
// Cyberpunk Theme Colors
// Dark background
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
// Borders and Titles
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
// Text
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return event
}
return a.goBack()
}
return event
})
a.buildPages()
return a
}
// Run starts the TUI event loop.
func (a *App) Run() error {
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
}
func (a *App) buildPages() {
a.pages.AddPage("home", a.newHomePage(), true, true)
a.pageStack = []string{"home"}
}
func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
}
func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
if fn, ok := a.pageRefreshFns[prev]; ok {
fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
}
func (a *App) showError(msg string) {
modal := tview.NewModal().
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
if buttonLabel == "Delete" {
onConfirm()
}
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewFlex().
AddItem(tview.NewBox(), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(form, height, 1, true).
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
var modelTV *tview.TextView
if pageID == "home" {
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
// Cyberpunk Sidebar Styling
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
activeSuffix := "[-]"
inactivePrefix := "[#808080] "
inactiveSuffix := "[-]"
sbText := "\n\n" // Top padding
menuItem := func(id, label string) string {
if pageID == id {
return activePrefix + label + activeSuffix + "\n\n"
}
return inactivePrefix + label + inactiveSuffix + "\n\n"
}
sbText += menuItem("home", "HOME")
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
// Add a border around the content area if possible, or ensure content has its own border
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
return grid
}

View file

@ -0,0 +1,202 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuild := func() {
sel := list.GetCurrentItem()
list.Clear()
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
enabled = "enabled"
}
}
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
a.showChannelEditForm(configPath, name, chMap)
})
}
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuild()
a.pageRefreshFns["channels"] = rebuild
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
return a.goBack()
}
return event
})
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(" [::b]EDIT CHANNEL ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
fields := make(map[string]*tview.InputField)
var nameField *tview.InputField
if channelName == "" {
nameField = tview.NewInputField().
SetLabel("Channel Name").
SetText("").
SetFieldWidth(28)
form.AddFormItem(nameField)
}
for k, v := range existing {
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
continue
}
valStr := fmt.Sprintf("%v", v)
field := tview.NewInputField().
SetLabel(k).
SetText(valStr).
SetFieldWidth(28)
form.AddFormItem(field)
fields[k] = field
}
form.AddButton("SAVE", func() {
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
cfg["channels"] = make(map[string]any)
}
channels, ok := cfg["channels"].(map[string]any)
if !ok {
channels = make(map[string]any)
cfg["channels"] = channels
}
finalName := channelName
if channelName == "" {
if nameField == nil || nameField.GetText() == "" {
a.showError("Channel name is required")
return
}
finalName = nameField.GetText()
}
updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
}
}
for k, field := range fields {
val := field.GetText()
if val == "true" {
updated[k] = true
} else if val == "false" {
updated[k] = false
} else if num, err := strconv.Atoi(val); err == nil {
updated[k] = num
} else {
updated[k] = val
}
}
if channelName != "" && finalName != channelName {
delete(channels, channelName)
}
channels[finalName] = updated
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
a.showError(fmt.Sprintf("Failed to save config: %v", err))
return
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
return
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
a.showError(fmt.Sprintf("Failed to write config: %v", err))
return
}
a.hideModal("channel-edit")
a.goBack()
})
form.AddButton("CANCEL", func() {
a.hideModal("channel-edit")
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("channel-edit")
return nil
}
return event
})
a.showModal("channel-edit", centeredForm(form, 4, 20))
}

View file

@ -0,0 +1,261 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const pidFileName = "gateway.pid"
type gatewayStatus struct {
running bool
pid int
}
func getPidPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", pidFileName)
}
func isProcessRunning(pid int) bool {
if runtime.GOOS == "windows" {
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), strconv.Itoa(pid))
} else if runtime.GOOS == "darwin" {
cmd := exec.Command("ps", "aux")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
}
// Linux
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil
}
func getGatewayStatus() gatewayStatus {
pidPath := getPidPath()
data, err := os.ReadFile(pidPath)
if err != nil {
return gatewayStatus{running: false}
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return gatewayStatus{running: false}
}
if !isProcessRunning(pid) {
os.Remove(pidPath)
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: pid,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
pidPath := getPidPath()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pid, err := strconv.Atoi(line)
if err == nil {
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// 多次尝试确认进程已停止
for i := 0; i < 5; i++ {
if !isProcessRunning(status.pid) {
break
}
time.Sleep(200 * time.Millisecond)
}
os.Remove(getPidPath())
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}

View file

@ -0,0 +1,70 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem(
"CHANNELS: Configure communication channels",
"Manage Telegram/Discord/WeChat channels",
'n',
func() {
a.navigateTo("channels", a.newChannelsPage())
},
)
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
a.tapp.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
})
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.pageRefreshFns["home"] = rebuildList
return a.buildShell(
"home",
list,
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
)
}

View file

@ -0,0 +1,200 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[#ffff00]FETCHING MODELS...[-]")
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
var entries []modelEntry
var err error
if apiKey == "" {
err = fmt.Errorf("key is required")
} else {
entries, err = fetchModels(baseURL, apiKey)
}
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err == nil && len(entries) > 0 {
a.modelCache[cacheKey(schemeName, userName)] = entries
} else {
a.modelCache[cacheKey(schemeName, userName)] = nil
}
a.modelCacheMu.Unlock()
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
})
}()
table.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(modelIDs) {
return
}
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
Scheme: schemeName,
User: userName,
Model: modelIDs[row],
}
a.save()
// Trigger model selected callback if set
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.goBack()
return
}
var user tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
user = u
break
}
}
a.OnModelSelected(*scheme, user, modelIDs[row])
}
a.goBack()
})
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return u.Key
}
}
return ""
}
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result modelsAPIResponse
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
return result.Data, nil
}
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf(
"decode response: unrecognized shape: %s",
strings.TrimSpace(string(body[:min(len(body), 256)])),
)
}

View file

@ -0,0 +1,252 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
selectedSchemeName := func() string {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx >= 0 && idx < len(schemes) {
return schemes[idx].Name
}
return ""
}
rebuild := func() {
selName := selectedSchemeName()
table.Clear()
schemes := a.cfg.Provider.Schemes
for i, s := range schemes {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
users := a.cfg.Provider.UsersForScheme(s.Name)
n := len(users)
m := 0
for _, u := range users {
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
m++
}
}
table.SetCell(detailRow, 0,
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx < 0 || idx >= len(schemes) {
return
}
name := schemes[idx].Name
a.navigateTo("users", a.newUsersPage(name))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if idx < 0 || idx >= len(schemes) {
return nil
}
origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save()
a.refreshModelCache(func() {
rebuild()
for i, sc := range a.cfg.Provider.Schemes {
if sc.Name == s.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if idx < 0 || idx >= len(schemes) {
return nil
}
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
current := a.cfg.Provider.Schemes
newSchemes := make([]tuicfg.Scheme, 0, len(current))
for _, sc := range current {
if sc.Name != name {
newSchemes = append(newSchemes, sc)
}
}
a.cfg.Provider.Schemes = newSchemes
existing := a.cfg.Provider.Users
filtered := make([]tuicfg.User, 0, len(existing))
for _, u := range existing {
if u.Scheme != name {
filtered = append(filtered, u)
}
}
a.cfg.Provider.Users = filtered
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"schemes",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
typeIdx := 0
for i, t := range typeOptions {
if t == schemeType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if baseURL == "" {
a.showError("Base URL is required")
return
}
if existing == nil {
for _, s := range a.cfg.Provider.Schemes {
if s.Name == name {
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
return
}
}
}
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
a.showModal("scheme-form", centeredForm(form, 4, 12))
}

View file

@ -0,0 +1,261 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
findUserGlobalIdx := func(userName string) int {
for i, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return i
}
}
return -1
}
rowToVisIdx := func(row int) int { return row / 2 }
selectedUserName := func() string {
row, _ := table.GetSelection()
users := visibleUsers()
visIdx := rowToVisIdx(row)
if visIdx >= 0 && visIdx < len(users) {
return users[visIdx].Name
}
return ""
}
rebuild := func() {
selName := selectedUserName()
table.Clear()
users := visibleUsers()
for i, u := range users {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
table.SetCell(nameRow, 1,
tview.NewTableCell("").
SetSelectable(false),
)
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
visIdx := rowToVisIdx(row)
users := visibleUsers()
if visIdx < 0 || visIdx >= len(users) {
return
}
uName := users[visIdx].Name
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
visIdx := rowToVisIdx(row)
users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
cfgIdx := findUserGlobalIdx(origName)
if cfgIdx < 0 {
a.showError(fmt.Sprintf("User %q no longer exists", origName))
return
}
a.cfg.Provider.Users[cfgIdx] = u
a.save()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"users",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
typeIdx := 0
for i, t := range typeOptions {
if t == userType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if existing == nil {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == name {
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
return
}
}
}
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
AddButton("CANCEL", func() {
a.hideModal("user-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
return nil
}
return event
})
a.showModal("user-form", centeredForm(form, 4, 13))
}

View file

@ -8,7 +8,11 @@
"temperature": 0.7,
"max_tool_iterations": 20,
"summarize_message_threshold": 20,
"summarize_token_percent": 75
"summarize_token_percent": 75,
"tool_feedback": {
"enabled": false,
"max_args_length": 300
}
}
},
"model_list": [
@ -125,7 +129,8 @@
"verification_token": "",
"allow_from": [],
"reasoning_channel_id": "",
"random_reaction_emoji": []
"random_reaction_emoji": [],
"is_lark": false
},
"dingtalk": {
"enabled": false,
@ -202,6 +207,8 @@
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"enabled": false,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
@ -209,6 +216,25 @@
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
},
"pico": {
"enabled": false,
"token": "YOUR_PICO_TOKEN",
"allow_token_query": false,
"allow_origins": [],
"ping_interval": 30,
"read_timeout": 60,
"max_connections": 100,
"allow_from": []
},
"pico_client": {
"enabled": false,
"url": "wss://remote-pico-server/pico/ws",
"token": "YOUR_PICO_TOKEN",
"session_id": "",
"ping_interval": 30,
"read_timeout": 60,
"allow_from": []
},
"irc": {
"enabled": false,
"server": "irc.libera.chat:6697",

View file

@ -13,25 +13,27 @@
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
"allow_from": [],
"is_lark": false
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------------ | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用飞书频道 |
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
| app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token |
| allow_from | array | 否 | 用户ID白名单空表示所有用户 |
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
| 字段 | 类型 | 必填 | 描述 |
| --------------------- | ------ | ---- | ------------------------------------------------------------------------------------------------ |
| enabled | bool | 是 | 是否启用飞书频道 |
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
| app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token |
| allow_from | array | 否 | 用户ID白名单空表示所有用户 |
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
| is_lark | bool | 否 | 是否使用 Lark 国际版域名(`open.larksuite.com`),默认为 `false`(使用飞书域名 `open.feishu.cn` |
## 设置流程
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
1. 前往 [飞书开放平台](https://open.feishu.cn/)(国际版用户请前往 [Lark 开放平台](https://open.larksuite.com/)创建应用程序
2. 获取 App ID 和 App Secret
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)

View file

@ -11,18 +11,20 @@ PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
"allow_from": [],
"max_base64_file_size_mib": 0
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------- | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用 QQ Channel |
| app_id | string | 是 | QQ 机器人应用的 App ID |
| app_secret | string | 是 | QQ 机器人应用的 App Secret |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
| 字段 | 类型 | 必填 | 描述 |
| -------------------- | ------ | ---- | ------------------------------------------------------------ |
| enabled | bool | 是 | 是否启用 QQ Channel |
| app_id | string | 是 | QQ 机器人应用的 App ID |
| app_secret | string | 是 | QQ 机器人应用的 App Secret |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
| max_base64_file_size_mib | int | 否 | 本地文件转 base64 上传的最大体积,单位 MiB`0` 表示不限制。仅影响本地文件,不影响 URL 直传 |
## 设置流程

View file

@ -1,6 +1,9 @@
# 企业微信智能机器人 (AI Bot)
企业微信智能机器人AI Bot是企业微信官方提供的 AI 对话接入方式,支持私聊与群聊,内置流式响应协议,并支持超时后通过 `response_url` 主动推送最终回复。
企业微信智能机器人AI Bot是企业微信官方提供的 AI 对话接入方式支持私聊与群聊内置流式响应协议。PicoClaw 当前同时支持两种接入模式:
- WebSocket 长连接模式:使用 `bot_id` + `secret`,优先级更高,推荐使用
- Webhook 短连接模式:使用 `token` + `encoding_aes_key`,兼容传统回调,并支持超时后通过 `response_url` 主动推送最终回复
## 与其他 WeCom 通道的对比
@ -14,6 +17,25 @@
## 配置
### WebSocket 长连接模式(推荐)
```json
{
"channels": {
"wecom_aibot": {
"enabled": true,
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"allow_from": [],
"welcome_message": "你好!有什么可以帮助你的吗?",
"max_steps": 10
}
}
}
```
### Webhook 短连接模式
```json
{
"channels": {
@ -24,38 +46,68 @@
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "你好!有什么可以帮助你的吗?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly.",
"max_steps": 10
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | -------------------------------------------------- |
| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-aibot |
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
| reply_timeout | int | 否 | 回复超时时间默认5 |
| max_steps | int | 否 | Agent 最大执行步骤数默认10 |
### WebSocket 模式字段
| 字段 | 类型 | 必填 | 描述 |
|--------|--------|------|--------------------------------------------|
| bot_id | string | 是 | AI Bot 的唯一标识,在 AI Bot 管理页面配置 |
| secret | string | 是 | AI Bot 的密钥,在 AI Bot 管理页面配置 |
### Webhook 模式字段
| 字段 | 类型 | 必填 | 描述 |
|------------------|--------|------|----------------------------------------------|
| token | string | 是 | 回调验证令牌,在 AI Bot 管理页面配置 |
| encoding_aes_key | string | 是 | 43 字符 AES 密钥,在 AI Bot 管理页面随机生成 |
| webhook_path | string | 否 | Webhook 路径,默认 `/webhook/wecom-aibot` |
| processing_message | string | 否 | 流式超时后返回给用户的提示语 |
### 通用字段
| 字段 | 类型 | 必填 | 描述 |
|-----------------|--------|------|------------------------------------------|
| allow_from | array | 否 | 用户 ID 白名单,空数组表示允许所有用户 |
| welcome_message | string | 否 | 用户进入聊天时发送的欢迎语,留空则不发送 |
| reply_timeout | int | 否 | 回复超时时间默认5 |
| max_steps | int | 否 | Agent 最大执行步骤数默认10 |
## 模式选择
- 当 `bot_id``secret` 同时存在时PicoClaw 会优先使用 WebSocket 长连接模式
- 否则,当 `token``encoding_aes_key` 同时存在时PicoClaw 会使用 Webhook 短连接模式
## 设置流程
### WebSocket 长连接模式
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
3. 在 AI Bot 配置页面,配置 Bot 的名称、头像等信息,获取 `Bot ID``Secret`
4. 在 PicoClaw 配置文件中添加上述配置,重启 PicoClaw
### Webhook 短连接模式
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → "智能机器人",创建或选择一个 AI Bot
3. 在 AI Bot 配置页面,填写"消息接收"信息:
- **URL**`http://<your-server-ip>:18791/webhook/wecom-aibot`
- **Token**:随机生成或自定义
- **EncodingAESKey**:点击"随机生成",得到 43 字符密钥
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存(企业微信会发送验证请求)
4. 将 Token 和 EncodingAESKey 填入 PicoClaw 配置文件,启动服务后回到管理后台保存
> [!TIP]
> 服务器需要能被企业微信服务器访问。如在内网/本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
> 服务器需要能被企业微信服务器访问。如在内网本地开发,可使用 [ngrok](https://ngrok.com) 或 frp 做内网穿透。
## 流式响应协议
## Webhook 模式的流式响应协议
WeCom AI Bot 使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
Webhook 模式使用"流式拉取"协议,区别于普通 Webhook 的一次性回复:
```
用户发消息
@ -71,16 +123,24 @@ PicoClaw 立即返回 {finish: false}Agent 开始处理)
└─ Agent 完成 → 返回 {finish: true, content: "回答内容"}
```
**超时处理**(任务超过 30 秒):
**超时处理**(任务超过 30 秒):
若 Agent 处理时间超过约 30 秒(企业微信最大轮询窗口为 6 分钟)PicoClaw 会:
若 Agent 处理时间超过轮询窗口PicoClaw 会:
1. 立即关闭流,向用户显示「⏳ 正在处理中,请稍候,结果将稍后发送。」
1. 立即关闭流,向用户显示 `processing_message` 提示语
2. Agent 继续在后台运行
3. Agent 完成后,通过消息中携带的 `response_url` 将最终回复主动推送给用户
> `response_url` 由企业微信颁发,有效期 1 小时,只可使用一次,无需加密,直接 POST markdown 消息体即可。
## 超时提示语
配置 `processing_message` 后,当 Webhook 模式的流式轮询超时并切换到 `response_url` 主动推送模式时PicoClaw 会先返回这段提示语来结束当前流。
```json
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
```
## 欢迎语
配置 `welcome_message` 后,当用户打开与 AI Bot 的聊天窗口时(`enter_chat` 事件PicoClaw 会自动回复该欢迎语。留空则静默忽略。
@ -91,11 +151,17 @@ PicoClaw 立即返回 {finish: false}Agent 开始处理)
## 常见问题
### WebSocket 模式无法连接
- 检查 `bot_id``secret` 是否填写正确
- 查看日志中是否有 WebSocket 连接或鉴权失败信息
- 确认服务器可以访问企业微信长连接接口
### 回调 URL 验证失败
- 确认服务器防火墙已开放对应端口(默认 18791
- 确认 `token``encoding_aes_key` 填写正确
- 检查 PicoClaw 日志是否收到了来自企业微信的 GET 请求
- 确认服务器防火墙已开放对应端口
- 检查 PicoClaw 日志是否收到了来自企业微信的验证请求
### 消息没有回复
@ -105,12 +171,12 @@ PicoClaw 立即返回 {finish: false}Agent 开始处理)
### 超长任务没有收到最终推送
- 确认消息回调中携带了 `response_url`(仅企业微信新版 AI Bot 支持)
- 确认服务器能主动访问外网(需向 `response_url` POST 请求)
- 确认消息回调中携带了 `response_url`
- 确认服务器能主动访问外网
- 查看日志关键词 `response_url mode``Sending reply via response_url`
## 参考文档
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/100719)
- [企业微信 AI Bot 接入文档](https://developer.work.weixin.qq.com/document/path/101463)
- [流式响应协议说明](https://developer.work.weixin.qq.com/document/path/100719)
- [response_url 主动回复](https://developer.work.weixin.qq.com/document/path/101138)

View file

@ -414,7 +414,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
"welcome_message": "Hello! How can I help you?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}

View file

@ -42,13 +42,15 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── state/ # Persistent state (last channel, etc.)
├── cron/ # Scheduled jobs database
├── skills/ # Custom skills
├── AGENTS.md # Agent behavior guide
├── AGENT.md # Agent behavior guide
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul
└── USER.md # User preferences
```
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
### Skill Sources
By default, skills are loaded from:
@ -69,6 +71,135 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
### Agent Bindings (Route messages to specific agents)
Use `bindings` in `config.json` to route incoming messages to different agents by channel/account/context.
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt-4o-mini"
},
"list": [
{ "id": "main", "default": true, "name": "Main Assistant" },
{ "id": "support", "name": "Support Assistant" },
{ "id": "sales", "name": "Sales Assistant" }
]
},
"bindings": [
{
"agent_id": "support",
"match": {
"channel": "telegram",
"account_id": "*",
"peer": { "kind": "direct", "id": "user123" }
}
},
{
"agent_id": "sales",
"match": {
"channel": "discord",
"account_id": "my-discord-bot",
"guild_id": "987654321"
}
}
]
}
```
#### `bindings` fields
| Field | Required | Description |
|-------|----------|-------------|
| `agent_id` | Yes | Target agent id in `agents.list` |
| `match.channel` | Yes | Channel name (e.g. `telegram`, `discord`) |
| `match.account_id` | No | Channel account filter. Use `"*"` for all accounts of that channel. If omitted, only default account is matched |
| `match.peer.kind` + `match.peer.id` | No | Exact peer match (e.g. direct chat / topic / group id) |
| `match.guild_id` | No | Guild/server-level match |
| `match.team_id` | No | Team/workspace-level match |
#### Matching priority
When multiple bindings exist, PicoClaw resolves in this order:
1. `peer`
2. `parent_peer` (for thread/topic parent contexts)
3. `guild_id`
4. `team_id`
5. `account_id` (non-wildcard)
6. channel wildcard (`account_id: "*"`)
7. default agent
If a binding points to a missing `agent_id`, PicoClaw falls back to the default agent.
#### How matching works (step-by-step)
1. PicoClaw first filters bindings by `match.channel` (must equal current channel).
2. It then filters by `match.account_id`:
- omitted: match only the channel's default account
- `"*"`: match all accounts on this channel
- explicit value: exact account id match (case-insensitive)
3. From the remaining candidates, it applies the priority chain above and stops at the first hit.
In other words: **channel + account form the candidate set; peer/guild/team then decide final winner**.
#### Common recipes
**1) Route one specific DM user to a specialist agent**
```json
{
"agent_id": "support",
"match": {
"channel": "telegram",
"account_id": "*",
"peer": { "kind": "direct", "id": "user123" }
}
}
```
**2) Route one Discord server (guild) to a dedicated agent**
```json
{
"agent_id": "sales",
"match": {
"channel": "discord",
"account_id": "my-discord-bot",
"guild_id": "987654321"
}
}
```
**3) Route all remaining traffic of a channel to a fallback agent**
```json
{
"agent_id": "main",
"match": {
"channel": "discord",
"account_id": "*"
}
}
```
#### Authoring guidelines (important)
- Keep exactly one clear default agent in `agents.list` (`"default": true`).
- Put specific rules (`peer`, `guild_id`, `team_id`) and broad rules (`account_id: "*"` only) together safely; priority already guarantees specific rules win.
- Avoid duplicate rules with the same specificity and match values. If duplicates exist, the first matching entry in the config array wins.
- Ensure every `agent_id` exists in `agents.list`; unknown IDs silently fall back to default.
#### Troubleshooting checklist
- **Rule not taking effect?** Check `match.channel` spelling first (must be exact).
- **Expected account-specific routing but still using default?** Verify `match.account_id` equals actual runtime account id.
- **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths.
- **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled.
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.

View file

@ -31,3 +31,69 @@ When this flag is active, the global truncation function is disabled. This is ex
* Verifying the exact syntax of the messages sent to the provider.
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
* Debugging the session history saved in memory.
## Tool Call Visibility in Debug Logs
When debug mode is active, the agent emits structured log entries at each stage of the tool execution lifecycle. These entries carry a `component=agent` label and use `INFO` or `DEBUG` level depending on the amount of detail:
| Log message | Level | Key fields | Description |
|---|---|---|---|
| `LLM requested tool calls` | INFO | `tools`, `count`, `iteration` | List of tool names the model decided to call |
| `Tool call: <name>(<args>)` | INFO | `tool`, `iteration` | The tool name and a preview of its arguments (truncated to 200 chars) |
| `Sent tool result to user` | DEBUG | `tool`, `content_len` | Fired when a tool result is forwarded to the chat channel |
| `TTL tick after tool execution` | DEBUG | `agent_id`, `iteration` | MCP tool-discovery TTL decrement after each tool round |
| `Async tool completed, publishing result` | INFO | `tool`, `content_len`, `channel` | Only for tools that run asynchronously in the background |
### Reading a tool call log entry
A typical synchronous tool call produces two consecutive lines in the console:
```
[...] [INFO] agent: LLM requested tool calls {tools=[web_search], count=1, iteration=1}
[...] [INFO] agent: Tool call: web_search({"query":"picoclaw release notes"}) {tool=web_search, iteration=1}
```
The arguments preview is hard-capped at **200 characters** in the logs regardless of the `--no-truncate` flag, because it belongs to the `INFO`-level path. Use `--no-truncate` together with `--debug` to see the full `tools_json` field emitted by the `Full LLM request` DEBUG entry, which contains every tool definition sent to the model.
## Real-Time Tool Feedback in Chat (tool_feedback)
Debug logs are server-side only. If you want the agent to send a visible notification directly into the chat channel every time it executes a tool—useful when sharing the bot with other users or for transparency—enable the `tool_feedback` feature in `config.json`:
```json
{
"agents": {
"defaults": {
"tool_feedback": {
"enabled": true,
"max_args_length": 300
}
}
}
}
```
When `enabled` is `true`, every tool call sends a short message to the chat before the tool result is returned to the model. The message looks like:
```bash
🔧 `web_search`
{"query": "picoclaw release notes"}
```
### Options
| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | bool | `false` | Send a chat notification for each tool call |
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
### Environment variables
Both fields can also be set via environment variables:
```bash
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
```
> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.

View file

@ -410,7 +410,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
"welcome_message": "Hello! How can I help you?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}

View file

@ -42,13 +42,14 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── state/ # État persistant (dernier canal, etc.)
├── cron/ # Base de données des tâches planifiées
├── skills/ # Compétences personnalisées
├── AGENTS.md # Guide de comportement de l'agent
├── AGENT.md # Guide de comportement de l'agent
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'agent
├── SOUL.md # Âme de l'agent
└── USER.md # Préférences utilisateur
```
> **Remarque :** Les modifications apportées à `AGENT.md`, `SOUL.md`, `USER.md` et `memory/MEMORY.md` sont détectées automatiquement au moment de l'exécution via le suivi de la date de modification (mtime). Il n'est **pas nécessaire de redémarrer le gateway** après avoir modifié ces fichiers — l'agent charge le nouveau contenu à la prochaine requête.
### Sources de Compétences
Par défaut, les compétences sont chargées depuis :

View file

@ -70,9 +70,32 @@ L'outil exec est utilisé pour exécuter des commandes shell.
| Config | Type | Par défaut | Description |
|------------------------|-------|------------|------------------------------------------------|
| `enabled` | bool | true | Activer l'outil exec |
| `enable_deny_patterns` | bool | true | Activer le blocage par défaut des commandes dangereuses |
| `custom_deny_patterns` | array | [] | Modèles de refus personnalisés (expressions régulières) |
### Désactivation de l'Outil Exec
Pour désactiver complètement l'outil `exec`, définissez `enabled` à `false` :
**Via le fichier de configuration :**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**Via la variable d'environnement :**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **Note :** Lorsqu'il est désactivé, l'agent ne pourra pas exécuter de commandes shell. Cela affecte également la capacité de l'outil Cron à exécuter des commandes shell planifiées.
### Fonctionnalité
- **`enable_deny_patterns`** : Définir à `false` pour désactiver complètement les modèles de blocage par défaut des commandes dangereuses
@ -329,6 +352,7 @@ Toutes les options de configuration peuvent être remplacées via des variables
Par exemple :
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

219
docs/it/configuration.md Normal file
View file

@ -0,0 +1,219 @@
# ⚙️ Guida alla Configurazione
> Torna al [README](../../README.md)
## ⚙️ Configurazione
File di configurazione: `~/.picoclaw/config.json`
### Variabili d'Ambiente
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
| Variabile | Descrizione | Percorso Predefinito |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
**Esempi:**
```bash
# Esegui picoclaw usando un file di configurazione specifico
# Il percorso del workspace verrà letto da quel file di configurazione
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
# Il workspace verrà creato in /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Usa entrambi per un setup completamente personalizzato
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Struttura del Workspace
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
```
~/.picoclaw/workspace/
├── sessions/ # Sessioni di conversazione e cronologia
├── memory/ # Memoria a lungo termine (MEMORY.md)
├── state/ # Stato persistente (ultimo canale, ecc.)
├── cron/ # Database dei job pianificati
├── skills/ # Skill personalizzate
├── AGENTS.md # Guida al comportamento dell'agent
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
├── IDENTITY.md # Identità dell'agent
├── SOUL.md # Anima dell'agent
└── USER.md # Preferenze dell'utente
```
> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
### Sorgenti delle Skill
Per impostazione predefinita, le skill vengono caricate da:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (globale)
3. `<current-working-directory>/skills` (builtin)
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Politica Unificata di Esecuzione dei Comandi
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
### 🔒 Sandbox di Sicurezza
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
#### Configurazione Predefinita
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true
}
}
}
```
| Opzione | Predefinito | Descrizione |
| ----------------------- | ----------------------- | ---------------------------------------------------- |
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
#### Strumenti Protetti
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
| Strumento | Funzione | Restrizione |
| ------------- | ------------------------- | ---------------------------------------------------- |
| `read_file` | Legge file | Solo file all'interno del workspace |
| `write_file` | Scrive file | Solo file all'interno del workspace |
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
| `edit_file` | Modifica file | Solo file all'interno del workspace |
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
#### Protezione Exec Aggiuntiva
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
* `format`, `mkfs`, `diskpart` — Formattazione del disco
* `dd if=` — Imaging del disco
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
* Fork bomb `:(){ :|:& };:`
### Controllo Accesso ai File
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
### Sicurezza Exec
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
Per ambienti ad alto rischio:
* Esamina gli script di build prima dell'esecuzione.
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
#### Esempi di Errore
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
```
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
```
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
**Metodo 1: File di configurazione**
```json
{
"agents": {
"defaults": {
"restrict_to_workspace": false
}
}
}
```
**Metodo 2: Variabile d'ambiente**
```bash
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
```
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
#### Coerenza dei Confini di Sicurezza
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
| Percorso di esecuzione | Confine di sicurezza |
| ---------------------- | --------------------------------- |
| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
### Heartbeat (Task Periodici)
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
```markdown
# Periodic Tasks
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
```
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
#### Task Asincroni con Spawn
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
```markdown
# Periodic Tasks
```

View file

@ -510,7 +510,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "こんにちは!何かお手伝いできますか?"
"welcome_message": "こんにちは!何かお手伝いできますか?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}

View file

@ -42,13 +42,15 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── state/ # 永続化状態 (最後のチャネルなど)
├── cron/ # スケジュールジョブデータベース
├── skills/ # カスタムスキル
├── AGENTS.md # Agent 動作ガイド
├── AGENT.md # Agent 動作ガイド
├── HEARTBEAT.md # 定期タスクプロンプト (30 分ごとにチェック)
├── IDENTITY.md # Agent アイデンティティ
├── SOUL.md # Agent ソウル/性格
└── USER.md # ユーザー設定
```
> **注意:** `AGENT.md``SOUL.md``USER.md` および `memory/MEMORY.md` への変更は、ファイル更新時刻mtimeの追跡により実行時に自動検出されます。これらのファイルを編集した後に **gateway を再起動する必要はありません** — Agent は次のリクエスト時に最新の内容を自動的に読み込みます。
### スキルソース
デフォルトでは、スキルは以下の順序で読み込まれます:

View file

@ -70,9 +70,32 @@ Exec ツールはシェルコマンドの実行に使用されます。
| 設定項目 | 型 | デフォルト | 説明 |
|------------------------|-------|------------|------------------------------------|
| `enabled` | bool | true | Exec ツールを有効にする |
| `enable_deny_patterns` | bool | true | デフォルトの危険コマンドブロックを有効にする |
| `custom_deny_patterns` | array | [] | カスタム拒否パターン(正規表現) |
### Exec ツールの無効化
`exec` ツールを完全に無効にするには、`enabled``false` に設定します:
**設定ファイル経由:**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**環境変数経由:**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **注意:** 無効にすると、エージェントはシェルコマンドを実行できなくなります。これは Cron ツールがスケジュールされたシェルコマンドを実行する能力にも影響します。
### 機能
- **`enable_deny_patterns`**`false` に設定すると、デフォルトの危険コマンドブロックパターンを完全に無効にします
@ -329,6 +352,7 @@ Skills ツールは ClawHub などのレジストリを通じたスキルの発
例:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

View file

@ -42,13 +42,15 @@ O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/wo
├── state/ # Estado persistente (último canal, etc.)
├── cron/ # Banco de dados de tarefas agendadas
├── skills/ # Skills personalizadas
├── AGENTS.md # Guia de comportamento do agente
├── AGENT.md # Guia de comportamento do agente
├── HEARTBEAT.md # Prompts de tarefas periódicas (verificados a cada 30 min)
├── IDENTITY.md # Identidade do agente
├── SOUL.md # Alma do agente
└── USER.md # Preferências do usuário
```
> **Nota:** Alterações em `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` são detectadas automaticamente em tempo de execução via rastreamento de data de modificação (mtime). **Não é necessário reiniciar o gateway** após editar esses arquivos — o agente carrega o novo conteúdo na próxima requisição.
### Fontes de Skills
Por padrão, as skills são carregadas de:

View file

@ -70,9 +70,32 @@ A ferramenta exec é usada para executar comandos shell.
| Config | Tipo | Padrão | Descrição |
|------------------------|-------|--------|-------------------------------------------------|
| `enabled` | bool | true | Habilitar a ferramenta exec |
| `enable_deny_patterns` | bool | true | Habilitar bloqueio padrão de comandos perigosos |
| `custom_deny_patterns` | array | [] | Padrões de negação personalizados (expressões regulares) |
### Desabilitando a Ferramenta Exec
Para desabilitar completamente a ferramenta `exec`, defina `enabled` como `false`:
**Via arquivo de configuração:**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**Via variável de ambiente:**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **Nota:** Quando desabilitada, o agent não poderá executar comandos shell. Isso também afeta a capacidade da ferramenta Cron de executar comandos shell agendados.
### Funcionalidade
- **`enable_deny_patterns`**: Defina como `false` para desabilitar completamente os padrões de bloqueio de comandos perigosos padrão
@ -329,6 +352,7 @@ Todas as opções de configuração podem ser substituídas via variáveis de am
Por exemplo:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

View file

@ -68,9 +68,32 @@ The exec tool is used to execute shell commands.
| Config | Type | Default | Description |
|------------------------|-------|---------|--------------------------------------------|
| `enabled` | bool | true | Enable the exec tool |
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
### Disabling the Exec Tool
To completely disable the `exec` tool, set `enabled` to `false`:
**Via config file:**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**Via environment variable:**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **Note:** When disabled, the agent will not be able to execute shell commands. This also affects the Cron tool's ability to run scheduled shell commands.
### Functionality
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
@ -158,7 +181,7 @@ and injected into the context for a configured number of turns (`ttl`).
| Config | Type | Default | Description |
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
| `enabled` | bool | false | Global default: if `true`, all MCP tools are hidden and loaded on-demand via search; if `false`, all tools are loaded into context. Individual servers can override this with the per-server `deferred` field. |
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
@ -169,16 +192,17 @@ and injected into the context for a configured number of turns (`ttl`).
### Per-Server Config
| Config | Type | Required | Description |
|------------|--------|----------|--------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
| `args` | array | no | Command arguments for stdio transport |
| `env` | object | no | Environment variables for stdio process |
| `env_file` | string | no | Path to environment file for stdio process |
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
| Config | Type | Required | Description |
|------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server |
| `deferred` | bool | no | Override deferred mode for this server only. `true` = tools are hidden and discoverable via search; `false` = tools are always visible in context. When omitted, the global `discovery.enabled` value applies. |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
| `args` | array | no | Command arguments for stdio transport |
| `env` | object | no | Environment variables for stdio process |
| `env_file` | string | no | Path to environment file for stdio process |
| `url` | string | sse/http | Endpoint URL for `sse`/`http` transport |
| `headers` | object | no | HTTP headers for `sse`/`http` transport |
### Transport Behavior
@ -291,6 +315,50 @@ dynamically only when requested by the user.*
}
```
#### 4) Mixed setup: per-server deferred override
*Discovery is enabled globally, but `filesystem` is pinned as always-visible while `context7` follows the global
default (deferred). `aws` explicitly opts in to deferred mode even though it is the same as the global default.*
```json
{
"tools": {
"mcp": {
"enabled": true,
"discovery": {
"enabled": true,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true
},
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
"deferred": false
},
"context7": {
"enabled": true,
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
},
"aws": {
"enabled": true,
"command": "npx",
"args": ["-y", "aws-mcp-server"],
"deferred": true
}
}
}
}
}
```
> **Tip:** `deferred` on a per-server basis is independent of `discovery.enabled`. You can keep
> `discovery.enabled: false` globally (all tools visible by default) and still mark individual
> high-volume servers as `"deferred": true` to avoid polluting the context with their tools.
## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub.
@ -334,6 +402,7 @@ All configuration options can be overridden via environment variables with the f
For example:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

View file

@ -410,7 +410,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "Hello! How can I help you?"
"welcome_message": "Hello! How can I help you?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}

View file

@ -42,13 +42,15 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── state/ # Trạng thái bền vững (kênh cuối, v.v.)
├── cron/ # Cơ sở dữ liệu tác vụ lên lịch
├── skills/ # Skill tùy chỉnh
├── AGENTS.md # Hướng dẫn hành vi agent
├── AGENT.md # Hướng dẫn hành vi agent
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính agent
├── SOUL.md # Linh hồn agent
└── USER.md # Tùy chọn người dùng
```
> **Lưu ý:** Các thay đổi đối với `AGENT.md`, `SOUL.md`, `USER.md``memory/MEMORY.md` được tự động phát hiện trong thời gian chạy thông qua theo dõi thời gian sửa đổi file (mtime). **Không cần khởi động lại gateway** sau khi chỉnh sửa các file này — agent sẽ tải nội dung mới vào yêu cầu tiếp theo.
### Nguồn Skill
Mặc định, skill được tải từ:

View file

@ -70,9 +70,32 @@ Công cụ exec được sử dụng để thực thi các lệnh shell.
| Cấu hình | Kiểu | Mặc định | Mô tả |
|--------------------------|-------|----------|------------------------------------------------|
| `enabled` | bool | true | Bật công cụ exec |
| `enable_deny_patterns` | bool | true | Bật chặn lệnh nguy hiểm mặc định |
| `custom_deny_patterns` | array | [] | Mẫu từ chối tùy chỉnh (biểu thức chính quy) |
### Vô hiệu hóa Công cụ Exec
Để hoàn toàn vô hiệu hóa công cụ `exec`, đặt `enabled` thành `false`:
**Qua tệp cấu hình:**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**Qua biến môi trường:**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **Lưu ý:** Khi bị vô hiệu hóa, agent sẽ không thể thực thi lệnh shell. Điều này cũng ảnh hưởng đến khả năng chạy lệnh shell theo lịch của công cụ Cron.
### Chức năng
- **`enable_deny_patterns`**: Đặt thành `false` để tắt hoàn toàn các mẫu chặn lệnh nguy hiểm mặc định
@ -329,6 +352,7 @@ Tất cả các tùy chọn cấu hình có thể được ghi đè qua biến m
Ví dụ:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

View file

@ -510,7 +510,8 @@ picoclaw gateway
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"allow_from": [],
"welcome_message": "你好!有什么可以帮你的?"
"welcome_message": "你好!有什么可以帮你的?",
"processing_message": "⏳ Processing, please wait. The results will be sent shortly."
}
}
}

View file

@ -42,13 +42,15 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── state/ # 持久化状态 (最后一次频道等)
├── cron/ # 定时任务数据库
├── skills/ # 自定义技能
├── AGENTS.md # Agent 行为指南
├── AGENT.md # Agent 行为指南
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格
└── USER.md # 用户偏好
```
> **提示:**`AGENT.md``SOUL.md``USER.md``memory/MEMORY.md` 的修改会通过文件修改时间mtime在运行时自动检测。**无需重启 gateway**Agent 将在下一次请求时自动加载最新内容。
### 技能来源 (Skill Sources)
默认情况下,技能会按以下顺序加载:

View file

@ -70,9 +70,32 @@ Exec 工具用于执行 shell 命令。
| 配置项 | 类型 | 默认值 | 描述 |
|------------------------|-------|--------|--------------------------------|
| `enabled` | bool | true | 启用 exec 工具 |
| `enable_deny_patterns` | bool | true | 启用默认的危险命令拦截 |
| `custom_deny_patterns` | array | [] | 自定义拒绝模式(正则表达式) |
### 禁用 Exec 工具
要完全禁用 `exec` 工具,请将 `enabled` 设置为 `false`
**通过配置文件:**
```json
{
"tools": {
"exec": {
"enabled": false
}
}
}
```
**通过环境变量:**
```bash
PICOCLAW_TOOLS_EXEC_ENABLED=false
```
> **注意:** 禁用后,代理将无法执行 shell 命令。这也会影响 Cron 工具运行计划 shell 命令的能力。
### 功能说明
- **`enable_deny_patterns`**:设为 `false` 可完全禁用默认的危险命令拦截模式
@ -329,6 +352,7 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
例如:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLED=false`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`

View file

@ -0,0 +1,47 @@
# pico-echo-server
Minimal Pico Protocol WebSocket server for testing the `pico_client` channel.
## Usage
```bash
go run ./examples/pico-echo-server -addr :9090 -token secret
```
### Flags
| Flag | Default | Description |
|----------|---------|------------------------------------|
| `-addr` | `:9090` | Listen address |
| `-token` | (none) | Auth token; empty disables auth |
## How it works
- Listens for WebSocket connections at `/ws`
- Authenticates via `Authorization: Bearer <token>` header or `?token=<token>` query param
- Prints received `message.send` content to stdout
- Responds to `ping` with `pong`
- Lines typed into stdin are broadcast as `message.create` to all connected clients
## Testing with pico_client
1. Start the server:
```bash
go run ./examples/pico-echo-server -token mytoken
```
2. Configure `pico_client` in your `config.json`:
```json
{
"channels": {
"pico_client": {
"enabled": true,
"url": "ws://localhost:9090/ws",
"token": "mytoken",
"session_id": "test-session"
}
}
}
```
3. Start picoclaw — the client connects and you can exchange messages interactively via stdin/stdout.

View file

@ -0,0 +1,160 @@
// pico-echo-server is a minimal Pico Protocol WebSocket server for testing
// the pico_client channel. It accepts connections, prints received messages
// to stdout, and forwards stdin lines as message.create to all connected clients.
//
// Usage:
//
// go run ./examples/pico-echo-server -addr :9090 -token secret
//
// Then configure pico_client with url=ws://localhost:9090/ws&token=secret.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type picoMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
var upgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
type server struct {
token string
mu sync.Mutex
conns map[*websocket.Conn]string // conn → sessionID
}
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
if s.token != "" {
auth := r.Header.Get("Authorization")
if auth != "Bearer "+s.token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade: %v", err)
return
}
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
sessionID = fmt.Sprintf("sess-%d", time.Now().UnixMilli())
}
s.mu.Lock()
s.conns[conn] = sessionID
s.mu.Unlock()
log.Printf("[+] client connected (session=%s)", sessionID)
defer func() {
s.mu.Lock()
delete(s.conns, conn)
s.mu.Unlock()
conn.Close()
log.Printf("[-] client disconnected (session=%s)", sessionID)
}()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("read error: %v", err)
}
return
}
var msg picoMessage
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("bad json: %v", err)
continue
}
switch msg.Type {
case "ping":
pong := picoMessage{Type: "pong", ID: msg.ID, Timestamp: time.Now().UnixMilli()}
conn.WriteJSON(pong)
case "message.send":
content, _ := msg.Payload["content"].(string)
fmt.Printf("[%s] %s\n", sessionID, content)
case "typing.start":
log.Printf("[%s] typing...", sessionID)
case "typing.stop":
log.Printf("[%s] stopped typing", sessionID)
default:
log.Printf("[%s] unknown type: %s", sessionID, msg.Type)
}
}
}
func (s *server) broadcast(content string) {
msg := picoMessage{
Type: "message.create",
Timestamp: time.Now().UnixMilli(),
Payload: map[string]any{"content": content},
}
s.mu.Lock()
defer s.mu.Unlock()
for conn, sid := range s.conns {
msg.SessionID = sid
if err := conn.WriteJSON(msg); err != nil {
log.Printf("write to %s failed: %v", sid, err)
}
}
}
func main() {
addr := flag.String("addr", ":9090", "listen address")
token := flag.String("token", "", "auth token (empty = no auth)")
flag.Parse()
s := &server{
token: *token,
conns: make(map[*websocket.Conn]string),
}
http.HandleFunc("/ws", s.handleWS)
log.Printf("listening on %s", *addr)
log.Printf("connect with: ws://localhost%s/ws", *addr)
fmt.Println("Type messages to send to connected clients (Ctrl+C to quit):")
go func() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
s.broadcast(line)
log.Printf("[server] sent: %s", line)
}
}()
log.Fatal(http.ListenAndServe(*addr, nil))
}

31
go.mod
View file

@ -1,14 +1,15 @@
module github.com/sipeed/picoclaw
go 1.25.7
go 1.25.8
require (
github.com/BurntSushi/toml v1.6.0
fyne.io/systray v1.12.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/ergochat/irc-go v0.5.0
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
@ -17,7 +18,7 @@ require (
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.1
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/mymmrac/telego v1.7.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
@ -29,16 +30,16 @@ require (
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.40.0
golang.org/x/term v0.41.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.3
maunium.net/go/mautrix v0.26.4
modernc.org/sqlite v1.46.1
)
require (
filippo.io/edwards25519 v1.1.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@ -51,18 +52,18 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.mau.fi/util v0.9.6 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/text v0.34.0 // indirect
go.mau.fi/util v0.9.7 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
@ -92,8 +93,8 @@ require (
github.com/valyala/fastjson v1.6.10 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/crypto v0.49.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
)

70
go.sum
View file

@ -1,8 +1,10 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
@ -46,8 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -70,8 +72,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@ -140,8 +142,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@ -158,8 +160,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -179,8 +181,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
@ -235,8 +237,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@ -250,16 +252,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -273,8 +275,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@ -284,8 +286,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -307,15 +309,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -323,8 +325,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -334,8 +336,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -365,8 +367,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=

View file

@ -3,13 +3,13 @@ package agent
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers"
@ -85,9 +85,11 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
if err != nil {
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
map[string]any{"error": err.Error()})
} else {
toolsRegistry.Register(execTool)
}
toolsRegistry.Register(execTool)
}
if cfg.Tools.IsToolEnabled("edit_file") {
@ -150,59 +152,14 @@ func NewAgentInstance(
}
// Resolve fallback candidates
modelCfg := providers.ModelConfig{
Primary: model,
Fallbacks: fallbacks,
}
resolveFromModelList := func(raw string) (string, bool) {
ensureProtocol := func(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
if cfg != nil {
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
return ensureProtocol(mc.Model), true
}
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue
}
if fullModel == raw {
return ensureProtocol(fullModel), true
}
_, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw {
return ensureProtocol(fullModel), true
}
}
}
return "", false
}
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
candidates := resolveModelCandidates(cfg, defaults.Provider, model, fallbacks)
// Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message.
var router *routing.Router
var lightCandidates []providers.FallbackCandidate
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
resolved := resolveModelCandidates(cfg, defaults.Provider, rc.LightModel, nil)
if len(resolved) > 0 {
router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel,
@ -210,8 +167,8 @@ func NewAgentInstance(
})
lightCandidates = resolved
} else {
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
rc.LightModel, agentID)
logger.WarnCF("agent", "Routing light model not found; routing disabled",
map[string]any{"light_model": rc.LightModel, "agent_id": agentID})
}
}
@ -320,7 +277,8 @@ func (a *AgentInstance) Close() error {
func initSessionStore(dir string) session.SessionStore {
store, err := memory.NewJSONLStore(dir)
if err != nil {
log.Printf("memory: init store: %v; using json sessions", err)
logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions",
map[string]any{"error": err.Error()})
return session.NewSessionManager(dir)
}
@ -328,11 +286,12 @@ func initSessionStore(dir string) session.SessionStore {
// Migration failure means the store could not write data.
// Fall back to SessionManager to avoid a split state where
// some sessions are in JSONL and others remain in JSON.
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
logger.WarnCF("agent", "Memory migration failed; falling back to json sessions",
map[string]any{"error": merr.Error()})
store.Close()
return session.NewSessionManager(dir)
} else if n > 0 {
log.Printf("memory: migrated %d session(s) to jsonl", n)
logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n})
}
return session.NewJSONLBackend(store)

View file

@ -246,3 +246,37 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatalf("exec output missing media content: %s", execResult.ForLLM)
}
}
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
workspace := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "test-model",
},
},
Tools: config.ToolsConfig{
ReadFile: config.ReadFileToolConfig{Enabled: true},
Exec: config.ExecConfig{
ToolConfig: config.ToolConfig{Enabled: true},
EnableDenyPatterns: true,
CustomDenyPatterns: []string{"[invalid-regex"},
},
},
}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
if agent == nil {
t.Fatal("expected agent instance, got nil")
}
if _, ok := agent.Tools.Get("exec"); ok {
t.Fatal("exec tool should not be registered when exec config is invalid")
}
if _, ok := agent.Tools.Get("read_file"); !ok {
t.Fatal("read_file tool should still be registered")
}
}

View file

@ -49,6 +49,7 @@ type AgentLoop struct {
cmdRegistry *commands.Registry
mcp mcpRuntime
mu sync.RWMutex
reloadFunc func() error
// Track active requests for safe provider cleanup
activeRequests sync.WaitGroup
}
@ -239,6 +240,11 @@ func registerSharedTools(
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Clone the parent's tool registry so subagents can use all
// tools registered so far (file, web, etc.) but NOT spawn/
// spawn_status which are added below — preventing recursive
// subagent spawning.
subagentManager.SetTools(agent.Tools.Clone())
if spawnEnabled {
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
@ -272,58 +278,64 @@ func (al *AgentLoop) Run(ctx context.Context) error {
return nil
}
// Process message
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
// if al.mediaStore != nil && msg.MediaScope != "" {
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
// logger.WarnCF("agent", "Failed to release media", map[string]any{
// "scope": msg.MediaScope,
// "error": releaseErr.Error(),
// })
// }
// }
// }()
func() {
defer func() {
if al.channelManager != nil {
al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
}
}()
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
// if al.mediaStore != nil && msg.MediaScope != "" {
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
// logger.WarnCF("agent", "Failed to release media", map[string]any{
// "scope": msg.MediaScope,
// "error": releaseErr.Error(),
// })
// }
// }
// }()
response, err := al.processMessage(ctx, msg)
if err != nil {
response = fmt.Sprintf("Error processing message: %v", err)
}
response, err := al.processMessage(ctx, msg)
if err != nil {
response = fmt.Sprintf("Error processing message: %v", err)
}
if response != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
// Use default agent's tools to check (message tool is shared).
alreadySent := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
if response != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
// Use default agent's tools to check (message tool is shared).
alreadySent := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
}
}
}
}
if !alreadySent {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"content_len": len(response),
if !alreadySent {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
} else {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": msg.Channel},
)
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"content_len": len(response),
})
} else {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": msg.Channel},
)
}
}
}
}()
default:
time.Sleep(time.Microsecond * 200)
}
@ -493,6 +505,11 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
al.transcriber = t
}
// SetReloadFunc sets the callback function for triggering config reload.
func (al *AgentLoop) SetReloadFunc(fn func() error) {
al.reloadFunc = fn
}
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
@ -1345,6 +1362,22 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration,
})
// Send tool feedback to chat channel if enabled
if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && opts.Channel != "" {
feedbackPreview := utils.Truncate(
string(argsJSON),
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
feedbackMsg := fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", tc.Name, feedbackPreview)
fbCtx, fbCancel := context.WithTimeout(ctx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: feedbackMsg,
})
fbCancel()
}
// Create async callback for tools that implement AsyncExecutor.
// When the background work completes, this publishes the result
// as an inbound system message so processSystemMessage routes it
@ -1484,7 +1517,7 @@ func (al *AgentLoop) selectCandidates(
history []providers.Message,
) (candidates []providers.FallbackCandidate, model string) {
if agent.Router == nil || len(agent.LightCandidates) == 0 {
return agent.Candidates, agent.Model
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
}
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
@ -1495,7 +1528,7 @@ func (al *AgentLoop) selectCandidates(
"score": score,
"threshold": agent.Router.Threshold(),
})
return agent.Candidates, agent.Model
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
}
logger.InfoCF("agent", "Model routing: light model selected",
@ -1505,7 +1538,7 @@ func (al *AgentLoop) selectCandidates(
"score": score,
"threshold": agent.Router.Threshold(),
})
return agent.LightCandidates, agent.Router.LightModel()
return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel())
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
@ -1960,13 +1993,45 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return nil
},
}
rt.ReloadConfig = func() error {
if al.reloadFunc == nil {
return fmt.Errorf("reload not configured")
}
return al.reloadFunc()
}
if agent != nil {
rt.GetModelInfo = func() (string, string) {
return agent.Model, cfg.Agents.Defaults.Provider
return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider)
}
rt.SwitchModel = func(value string) (string, error) {
value = strings.TrimSpace(value)
modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace)
if err != nil {
return "", err
}
nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg)
if err != nil {
return "", fmt.Errorf("failed to initialize model %q: %w", value, err)
}
nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks)
if len(nextCandidates) == 0 {
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
}
oldModel := agent.Model
oldProvider := agent.Provider
agent.Model = value
agent.Provider = nextProvider
agent.Candidates = nextCandidates
agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel)
if oldProvider != nil && oldProvider != nextProvider {
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
stateful.Close()
}
}
return oldModel, nil
}

View file

@ -11,6 +11,7 @@ import (
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/tools"
@ -111,6 +112,12 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
// Determine whether this server's tools should be deferred (hidden).
// Per-server "deferred" field takes precedence over the global Discovery.Enabled.
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
@ -120,7 +127,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
if registerAsHidden {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
@ -133,6 +140,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
"deferred": registerAsHidden,
})
}
}
@ -198,3 +206,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
return al.mcp.getInitErr()
}
// serverIsDeferred reports whether an MCP server's tools should be registered
// as hidden (deferred/discovery mode).
//
// The per-server Deferred field takes precedence over the global discoveryEnabled
// default. When Deferred is nil, discoveryEnabled is used as the fallback.
func serverIsDeferred(discoveryEnabled bool, serverCfg config.MCPServerConfig) bool {
if !discoveryEnabled {
return false
}
if serverCfg.Deferred != nil {
return *serverCfg.Deferred
}
return true
}

View file

@ -0,0 +1,75 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func boolPtr(b bool) *bool { return &b }
func TestServerIsDeferred(t *testing.T) {
tests := []struct {
name string
discoveryEnabled bool
serverDeferred *bool
want bool
}{
// --- global false always wins: per-server deferred is ignored ---
{
name: "global false: per-server deferred=true is ignored",
discoveryEnabled: false,
serverDeferred: boolPtr(true),
want: false,
},
{
name: "global false: per-server deferred=false stays false",
discoveryEnabled: false,
serverDeferred: boolPtr(false),
want: false,
},
// --- global true: per-server override applies ---
{
name: "global true: per-server deferred=false opts out",
discoveryEnabled: true,
serverDeferred: boolPtr(false),
want: false,
},
{
name: "global true: per-server deferred=true stays true",
discoveryEnabled: true,
serverDeferred: boolPtr(true),
want: true,
},
// --- no per-server override: fall back to global ---
{
name: "no per-server field, global discovery enabled",
discoveryEnabled: true,
serverDeferred: nil,
want: true,
},
{
name: "no per-server field, global discovery disabled",
discoveryEnabled: false,
serverDeferred: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serverCfg := config.MCPServerConfig{Deferred: tt.serverDeferred}
got := serverIsDeferred(tt.discoveryEnabled, serverCfg)
if got != tt.want {
t.Errorf("serverIsDeferred(discoveryEnabled=%v, deferred=%v) = %v, want %v",
tt.discoveryEnabled, tt.serverDeferred, got, tt.want)
}
})
}
}

View file

@ -2,7 +2,10 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
@ -444,6 +447,46 @@ type testHelper struct {
al *AgentLoop
}
func newChatCompletionTestServer(
t *testing.T,
label string,
response string,
calls *int,
model *string,
) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("%s server path = %q, want /chat/completions", label, r.URL.Path)
}
*calls = *calls + 1
defer r.Body.Close()
var req struct {
Model string `json:"model"`
}
decodeErr := json.NewDecoder(r.Body).Decode(&req)
if decodeErr != nil {
t.Fatalf("decode %s request: %v", label, decodeErr)
}
*model = req.Model
w.Header().Set("Content-Type", "application/json")
encodeErr := json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": response},
"finish_reason": "stop",
},
},
})
if encodeErr != nil {
t.Fatalf("encode %s response: %v", label, encodeErr)
}
}))
}
func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {
// Use a short timeout to avoid hanging
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
@ -605,11 +648,25 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
Model: "before-switch",
Model: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
ModelList: []config.ModelConfig{
{
ModelName: "local",
Model: "openai/local-model",
APIKey: "test-key",
APIBase: "https://local.example.invalid/v1",
},
{
ModelName: "deepseek",
Model: "openrouter/deepseek/deepseek-v3.2",
APIKey: "test-key",
APIBase: "https://openrouter.ai/api/v1",
},
},
}
msgBus := bus.NewMessageBus()
@ -621,13 +678,13 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "/switch model to after-switch",
Content: "/switch model to deepseek",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
if !strings.Contains(switchResp, "Switched model from local to deepseek") {
t.Fatalf("unexpected /switch reply: %q", switchResp)
}
@ -641,7 +698,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
ID: "user1",
},
})
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
if !strings.Contains(showResp, "Current Model: deepseek (Provider: openrouter)") {
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
}
@ -650,6 +707,187 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
}
}
func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
Model: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
ModelList: []config.ModelConfig{
{
ModelName: "local",
Model: "openai/local-model",
APIKey: "test-key",
APIBase: "https://local.example.invalid/v1",
},
},
}
msgBus := bus.NewMessageBus()
provider := &countingMockProvider{response: "LLM reply"}
al := NewAgentLoop(cfg, msgBus, provider)
helper := testHelper{al: al}
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "/switch model to missing",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if switchResp != `model "missing" not found in model_list or providers` {
t.Fatalf("unexpected /switch error reply: %q", switchResp)
}
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "/show model",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if !strings.Contains(showResp, "Current Model: local (Provider: openai)") {
t.Fatalf("unexpected /show model reply after rejected switch: %q", showResp)
}
if provider.calls != 0 {
t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls)
}
}
func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
localCalls := 0
localModel := ""
localServer := newChatCompletionTestServer(t, "local", "local reply", &localCalls, &localModel)
defer localServer.Close()
remoteCalls := 0
remoteModel := ""
remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel)
defer remoteServer.Close()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
Model: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
ModelList: []config.ModelConfig{
{
ModelName: "local",
Model: "openai/Qwen3.5-35B-A3B",
APIKey: "local-key",
APIBase: localServer.URL,
},
{
ModelName: "deepseek",
Model: "openrouter/deepseek/deepseek-v3.2",
APIKey: "remote-key",
APIBase: remoteServer.URL,
},
},
}
msgBus := bus.NewMessageBus()
provider, _, err := providers.CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider() error = %v", err)
}
al := NewAgentLoop(cfg, msgBus, provider)
helper := testHelper{al: al}
firstResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "hello before switch",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if firstResp != "local reply" {
t.Fatalf("unexpected response before switch: %q", firstResp)
}
if localCalls != 1 {
t.Fatalf("local calls before switch = %d, want 1", localCalls)
}
if remoteCalls != 0 {
t.Fatalf("remote calls before switch = %d, want 0", remoteCalls)
}
if localModel != "Qwen3.5-35B-A3B" {
t.Fatalf("local model before switch = %q, want %q", localModel, "Qwen3.5-35B-A3B")
}
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "/switch model to deepseek",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if !strings.Contains(switchResp, "Switched model from local to deepseek") {
t.Fatalf("unexpected /switch reply: %q", switchResp)
}
secondResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "hello after switch",
Peer: bus.Peer{
Kind: "direct",
ID: "user1",
},
})
if secondResp != "remote reply" {
t.Fatalf("unexpected response after switch: %q", secondResp)
}
if localCalls != 1 {
t.Fatalf("local calls after switch = %d, want 1", localCalls)
}
if remoteCalls != 1 {
t.Fatalf("remote calls after switch = %d, want 1", remoteCalls)
}
if remoteModel != "deepseek-v3.2" {
t.Fatalf(
"remote model after switch = %q, want %q",
remoteModel,
"deepseek-v3.2",
)
}
}
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")

View file

@ -0,0 +1,97 @@
package agent
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) {
ensureProtocol := func(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
return func(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || cfg == nil {
return "", false
}
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
return ensureProtocol(mc.Model), true
}
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue
}
if fullModel == raw {
return ensureProtocol(fullModel), true
}
_, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw {
return ensureProtocol(fullModel), true
}
}
return "", false
}
}
func resolveModelCandidates(
cfg *config.Config,
defaultProvider string,
primary string,
fallbacks []string,
) []providers.FallbackCandidate {
return providers.ResolveCandidatesWithLookup(
providers.ModelConfig{
Primary: primary,
Fallbacks: fallbacks,
},
defaultProvider,
buildModelListResolver(cfg),
)
}
func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string {
if len(candidates) > 0 && strings.TrimSpace(candidates[0].Model) != "" {
return candidates[0].Model
}
return fallback
}
func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallback string) string {
if len(candidates) > 0 && strings.TrimSpace(candidates[0].Provider) != "" {
return candidates[0].Provider
}
return fallback
}
func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
modelCfg, err := cfg.GetModelConfig(strings.TrimSpace(modelName))
if err != nil {
return nil, err
}
clone := *modelCfg
if clone.Workspace == "" {
clone.Workspace = workspace
}
return &clone, nil
}

View file

@ -84,3 +84,64 @@ func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) s
content = mentionPlaceholderRegex.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
// extractCardImageKeys recursively extracts all image keys from a Feishu interactive card.
// Image keys are used to download images from Feishu API.
// Returns two slices: Feishu-hosted keys and external URLs.
func extractCardImageKeys(rawContent string) (feishuKeys []string, externalURLs []string) {
if rawContent == "" {
return nil, nil
}
var card map[string]any
if err := json.Unmarshal([]byte(rawContent), &card); err != nil {
return nil, nil
}
extractImageKeysRecursive(card, &feishuKeys, &externalURLs)
return feishuKeys, externalURLs
}
// isExternalURL returns true if the string is an external HTTP/HTTPS URL.
func isExternalURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// extractImageKeysRecursive traverses card structure to find all image keys.
// Collects both Feishu-hosted keys and external URLs separately.
func extractImageKeysRecursive(v any, feishuKeys, externalURLs *[]string) {
switch val := v.(type) {
case map[string]any:
// Check if this is an img element
if tag, ok := val["tag"].(string); ok {
switch tag {
case "img":
// Try img_key first (always Feishu-hosted)
if imgKey, ok := val["img_key"].(string); ok && imgKey != "" {
*feishuKeys = append(*feishuKeys, imgKey)
}
// Check src - could be Feishu key or external URL
if src, ok := val["src"].(string); ok && src != "" {
if isExternalURL(src) {
*externalURLs = append(*externalURLs, src)
} else {
*feishuKeys = append(*feishuKeys, src)
}
}
case "icon":
// Icon elements use icon_key
if iconKey, ok := val["icon_key"].(string); ok && iconKey != "" {
*feishuKeys = append(*feishuKeys, iconKey)
}
}
}
// Recurse into all nested structures
for _, child := range val {
extractImageKeysRecursive(child, feishuKeys, externalURLs)
}
case []any:
for _, item := range val {
extractImageKeysRecursive(item, feishuKeys, externalURLs)
}
}
}

View file

@ -290,3 +290,119 @@ func TestStripMentionPlaceholders(t *testing.T) {
})
}
}
func TestExtractCardImageKeys(t *testing.T) {
tests := []struct {
name string
content string
wantFeishuKeys []string
wantExternalURLs []string
}{
{
name: "empty content",
content: "",
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "invalid JSON",
content: "not json",
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "card with no images",
content: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"text"}]}}`,
wantFeishuKeys: nil,
wantExternalURLs: nil,
},
{
name: "single image with img_key",
content: `{"elements":[{"tag":"img","img_key":"img_abc123"}]}`,
wantFeishuKeys: []string{"img_abc123"},
wantExternalURLs: nil,
},
{
name: "single image with src as Feishu key",
content: `{"elements":[{"tag":"img","src":"img_xyz789"}]}`,
wantFeishuKeys: []string{"img_xyz789"},
wantExternalURLs: nil,
},
{
name: "multiple images",
content: `{"elements":[{"tag":"img","img_key":"img_1"},{"tag":"div","text":{"content":"text"}},{"tag":"img","img_key":"img_2"}]}`,
wantFeishuKeys: []string{"img_1", "img_2"},
wantExternalURLs: nil,
},
{
name: "nested image in columns",
content: `{"elements":[{"tag":"div","columns":[{"tag":"img","img_key":"img_col1"},{"tag":"img","img_key":"img_col2"}]}]}`,
wantFeishuKeys: []string{"img_col1", "img_col2"},
wantExternalURLs: nil,
},
{
name: "image in action",
content: `{"elements":[{"tag":"action","actions":[{"tag":"img","img_key":"img_action"}]}]}`,
wantFeishuKeys: []string{"img_action"},
wantExternalURLs: nil,
},
{
name: "icon element",
content: `{"elements":[{"tag":"icon","icon_key":"icon_123"}]}`,
wantFeishuKeys: []string{"icon_123"},
wantExternalURLs: nil,
},
{
name: "complex card with text and images",
content: `{"header":{"title":{"content":"Title"}},"elements":[{"tag":"div","text":{"content":"Description"}},{"tag":"img","img_key":"img_main"}]}`,
wantFeishuKeys: []string{"img_main"},
wantExternalURLs: nil,
},
{
name: "external URL in src",
content: `{"elements":[{"tag":"img","src":"https://example.com/image.png"}]}`,
wantFeishuKeys: nil,
wantExternalURLs: []string{"https://example.com/image.png"},
},
{
name: "mixed Feishu keys and external URLs",
content: `{"elements":[{"tag":"img","img_key":"img_feishu"},{"tag":"img","src":"https://cdn.example.com/external.jpg"},{"tag":"img","src":"img_another"}]}`,
wantFeishuKeys: []string{"img_feishu", "img_another"},
wantExternalURLs: []string{"https://cdn.example.com/external.jpg"},
},
{
name: "multiple external URLs",
content: `{"elements":[{"tag":"img","src":"https://a.com/1.png"},{"tag":"img","src":"http://b.com/2.jpg"}]}`,
wantFeishuKeys: nil,
wantExternalURLs: []string{"https://a.com/1.png", "http://b.com/2.jpg"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotFeishuKeys, gotExternalURLs := extractCardImageKeys(tt.content)
// Compare Feishu keys
if len(gotFeishuKeys) != len(tt.wantFeishuKeys) {
t.Errorf("extractCardImageKeys() feishuKeys = %v, want %v", gotFeishuKeys, tt.wantFeishuKeys)
return
}
for i, v := range gotFeishuKeys {
if v != tt.wantFeishuKeys[i] {
t.Errorf("extractCardImageKeys() feishuKeys[%d] = %q, want %q", i, v, tt.wantFeishuKeys[i])
}
}
// Compare external URLs
if len(gotExternalURLs) != len(tt.wantExternalURLs) {
t.Errorf("extractCardImageKeys() externalURLs = %v, want %v", gotExternalURLs, tt.wantExternalURLs)
return
}
for i, v := range gotExternalURLs {
if v != tt.wantExternalURLs[i] {
t.Errorf("extractCardImageKeys() externalURLs[%d] = %q, want %q", i, v, tt.wantExternalURLs[i])
}
}
})
}
}

View file

@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@ -54,11 +55,15 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
)
tc := newTokenCache()
opts := []lark.ClientOptionFunc{lark.WithTokenCache(tc)}
if cfg.IsLark {
opts = append(opts, lark.WithOpenBaseUrl(lark.LarkBaseUrl))
}
ch := &FeishuChannel{
BaseChannel: base,
config: cfg,
tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithTokenCache(tc)),
client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),
}
ch.SetOwner(ch)
return ch, nil
@ -83,10 +88,15 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
c.mu.Lock()
c.cancel = cancel
domain := lark.FeishuBaseUrl
if c.config.IsLark {
domain = lark.LarkBaseUrl
}
c.wsClient = larkws.NewClient(
c.config.AppID,
c.config.AppSecret,
larkws.WithEventHandler(dispatcher),
larkws.WithDomain(domain),
)
wsClient := c.wsClient
c.mu.Unlock()
@ -120,6 +130,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
}
// Send sends a message using Interactive Card format for markdown rendering.
// Falls back to plain text message if card sending fails (e.g., table limit exceeded).
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
@ -132,9 +143,38 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
// Build interactive card with markdown content
cardContent, err := buildMarkdownCard(msg.Content)
if err != nil {
return fmt.Errorf("feishu send: card build failed: %w", err)
// If card build fails, fall back to plain text
return c.sendText(ctx, msg.ChatID, msg.Content)
}
return c.sendCard(ctx, msg.ChatID, cardContent)
// First attempt: try sending as interactive card
err = c.sendCard(ctx, msg.ChatID, cardContent)
if err == nil {
return nil
}
// Check if error is due to card table limit (error code 11310)
// See: https://open.feishu.cn/document/server-docs/im-api/message-content-description/create_json
errMsg := err.Error()
isCardLimitError := strings.Contains(errMsg, "11310")
if isCardLimitError {
logger.WarnCF("feishu", "Card send failed (table limit), falling back to text message", map[string]any{
"chat_id": msg.ChatID,
"error": errMsg,
})
// Second attempt: fall back to plain text message
textErr := c.sendText(ctx, msg.ChatID, msg.Content)
if textErr == nil {
return nil
}
// If text also fails, return the text error
return textErr
}
// For other errors, return the original card error
return err
}
// EditMessage implements channels.MessageEditor.
@ -384,6 +424,15 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
mediaRefs = c.downloadInboundMedia(ctx, chatID, messageID, messageType, rawContent, store)
}
// For interactive cards, pass external image URLs via media refs.
// Keep content as valid raw JSON for downstream parsing.
if messageType == larkim.MsgTypeInteractive {
_, externalURLs := extractCardImageKeys(rawContent)
if len(externalURLs) > 0 {
mediaRefs = append(mediaRefs, externalURLs...)
}
}
// Append media tags to content (like Telegram does)
content = appendMediaTags(content, messageType, mediaRefs)
@ -519,6 +568,10 @@ func extractContent(messageType, rawContent string) string {
// Pass raw JSON to LLM — structured rich text is more informative than flattened plain text
return rawContent
case larkim.MsgTypeInteractive:
// Pass raw JSON to LLM — structured card is more informative than flattened text
return rawContent
case larkim.MsgTypeImage:
// Image messages don't have text content
return ""
@ -556,6 +609,18 @@ func (c *FeishuChannel) downloadInboundMedia(
refs = append(refs, ref)
}
case larkim.MsgTypeInteractive:
// Extract and download images embedded in interactive cards
feishuKeys, _ := extractCardImageKeys(rawContent)
// Download Feishu-hosted images via API
for _, imageKey := range feishuKeys {
ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope)
if ref != "" {
refs = append(refs, ref)
}
}
// External URLs are passed directly to LLM, not downloaded
case larkim.MsgTypeFile, larkim.MsgTypeAudio, larkim.MsgTypeMedia:
fileKey := extractFileKey(rawContent)
if fileKey == "" {
@ -676,11 +741,18 @@ func (c *FeishuChannel) downloadResource(
}
// appendMediaTags appends media type tags to content (like Telegram's "[image: photo]").
// For interactive cards, media tags are not appended because content is raw JSON
// and appending would produce invalid JSON format.
func appendMediaTags(content, messageType string, mediaRefs []string) string {
if len(mediaRefs) == 0 {
return content
}
// Don't append tags to JSON content (interactive cards) - would produce invalid JSON
if messageType == larkim.MsgTypeInteractive {
return content
}
var tag string
switch messageType {
case larkim.MsgTypeImage:
@ -729,6 +801,35 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
return nil
}
// sendText sends a plain text message to a chat (fallback when card fails).
func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error {
content, _ := json.Marshal(map[string]string{"text": text})
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeText).
Content(string(content)).
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
if err != nil {
return fmt.Errorf("feishu send text: %w", channels.ErrTemporary)
}
if !resp.Success() {
return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{
"chat_id": chatID,
})
return nil
}
// sendImage uploads an image and sends it as a message.
func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.File) error {
// Upload image to get image_key

View file

@ -75,6 +75,24 @@ func TestExtractContent(t *testing.T) {
rawContent: "",
want: "",
},
{
name: "interactive card returns raw JSON",
messageType: "interactive",
rawContent: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
want: `{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"Hello from card"}]}}`,
},
{
name: "interactive card with complex structure returns raw JSON",
messageType: "interactive",
rawContent: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
want: `{"header":{"title":{"tag":"plain_text","content":"Title"}},"elements":[{"tag":"div","text":{"tag":"lark_md","content":"Card content"}}]}`,
},
{
name: "interactive card invalid JSON returns as-is",
messageType: "interactive",
rawContent: `not valid json`,
want: `not valid json`,
},
}
for _, tt := range tests {
@ -151,6 +169,13 @@ func TestAppendMediaTags(t *testing.T) {
mediaRefs: []string{"ref1"},
want: "something [attachment]",
},
{
name: "interactive card with images returns content unchanged",
content: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
messageType: "interactive",
mediaRefs: []string{"ref1"},
want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`,
},
}
for _, tt := range tests {

View file

@ -86,10 +86,11 @@ type Manager struct {
mux *http.ServeMux
httpServer *http.Server
mu sync.RWMutex
placeholders sync.Map // "channel:chatID" → placeholderID (string)
typingStops sync.Map // "channel:chatID" → func()
reactionUndos sync.Map // "channel:chatID" → reactionEntry
streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message)
placeholders sync.Map // "channel:chatID" → placeholderID (string)
typingStops sync.Map // "channel:chatID" → func()
reactionUndos sync.Map // "channel:chatID" → reactionEntry
streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message)
channelHashes map[string]string // channel name → config hash
}
type asyncTask struct {
@ -136,6 +137,19 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
}
}
// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID.
// It is safe to call even when no typing indicator is active (no-op).
// Used by the agent loop to stop typing when processing completes (success, error, or panic),
// regardless of whether an outbound message is published.
func (m *Manager) InvokeTypingStop(channel, chatID string) {
key := channel + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok {
entry.stop()
}
}
}
// RecordReactionUndo registers a reaction undo function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
@ -194,20 +208,24 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
m := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
bus: messageBus,
config: cfg,
mediaStore: store,
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
bus: messageBus,
config: cfg,
mediaStore: store,
channelHashes: make(map[string]string),
}
// Register as streaming delegate so the agent loop can obtain streamers
messageBus.SetStreamDelegate(m)
if err := m.initChannels(); err != nil {
if err := m.initChannels(&cfg.Channels); err != nil {
return nil, err
}
// Store initial config hashes for all channels
m.channelHashes = toChannelHashes(cfg)
return m, nil
}
@ -295,15 +313,15 @@ func (m *Manager) initChannel(name, displayName string) {
}
}
func (m *Manager) initChannels() error {
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager")
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
if channels.Telegram.Enabled && channels.Telegram.Token != "" {
m.initChannel("telegram", "Telegram")
}
if m.config.Channels.WhatsApp.Enabled {
waCfg := m.config.Channels.WhatsApp
if channels.WhatsApp.Enabled {
waCfg := channels.WhatsApp
if waCfg.UseNative {
m.initChannel("whatsapp_native", "WhatsApp Native")
} else if waCfg.BridgeURL != "" {
@ -311,62 +329,68 @@ func (m *Manager) initChannels() error {
}
}
if m.config.Channels.Feishu.Enabled {
if channels.Feishu.Enabled {
m.initChannel("feishu", "Feishu")
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
if channels.Discord.Enabled && channels.Discord.Token != "" {
m.initChannel("discord", "Discord")
}
if m.config.Channels.MaixCam.Enabled {
if channels.MaixCam.Enabled {
m.initChannel("maixcam", "MaixCam")
}
if m.config.Channels.QQ.Enabled {
if channels.QQ.Enabled {
m.initChannel("qq", "QQ")
}
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" {
m.initChannel("dingtalk", "DingTalk")
}
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
if channels.Slack.Enabled && channels.Slack.BotToken != "" {
m.initChannel("slack", "Slack")
}
if m.config.Channels.Matrix.Enabled &&
if channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken != "" {
m.initChannel("matrix", "Matrix")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" {
m.initChannel("line", "LINE")
}
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" {
m.initChannel("onebot", "OneBot")
}
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
if channels.WeCom.Enabled && channels.WeCom.Token != "" {
m.initChannel("wecom", "WeCom")
}
if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" {
if m.config.Channels.WeComAIBot.Enabled &&
((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") ||
m.config.Channels.WeComAIBot.Token != "") {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
if channels.WeComApp.Enabled && channels.WeComApp.CorpID != "" {
m.initChannel("wecom_app", "WeCom App")
}
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
if channels.Pico.Enabled && channels.Pico.Token != "" {
m.initChannel("pico", "Pico")
}
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
if channels.PicoClient.Enabled && channels.PicoClient.URL != "" {
m.initChannel("pico_client", "Pico Client")
}
if channels.IRC.Enabled && channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
@ -888,6 +912,68 @@ func (m *Manager) GetEnabledChannels() []string {
return names
}
// Reload updates the config reference without restarting channels.
// This is used when channel config hasn't changed but other parts of the config have.
func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
m.mu.Lock()
defer m.mu.Unlock()
list := toChannelHashes(cfg)
added, removed := compareChannels(m.channelHashes, list)
for _, name := range removed {
// Stop all channels
channel := m.channels[name]
logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
})
if err := channel.Stop(ctx); err != nil {
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
}
go func() {
m.UnregisterChannel(name)
}()
}
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
cc, err := toChannelConfig(cfg, added)
if err != nil {
logger.ErrorC("channels", fmt.Sprintf("toChannelConfig error: %v", err))
return err
}
err = m.initChannels(cc)
if err != nil {
logger.ErrorC("channels", fmt.Sprintf("initChannels error: %v", err))
return err
}
for _, name := range added {
channel := m.channels[name]
logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
})
if err := channel.Start(ctx); err != nil {
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
continue
}
// Lazily create worker only after channel starts successfully
w := newChannelWorker(name, channel)
m.workers[name] = w
go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w)
go func() {
m.RegisterChannel(name, channel)
}()
}
m.config = cfg
m.channelHashes = toChannelHashes(cfg)
return nil
}
func (m *Manager) RegisterChannel(name string, channel Channel) {
m.mu.Lock()
defer m.mu.Unlock()

View file

@ -0,0 +1,86 @@
package channels
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func toChannelHashes(cfg *config.Config) map[string]string {
result := make(map[string]string)
ch := cfg.Channels
// should not be error
marshal, _ := json.Marshal(ch)
var channelConfig map[string]map[string]any
_ = json.Unmarshal(marshal, &channelConfig)
for key, value := range channelConfig {
if !value["enabled"].(bool) {
continue
}
valueBytes, _ := json.Marshal(value)
hash := md5.Sum(valueBytes)
result[key] = hex.EncodeToString(hash[:])
}
return result
}
func compareChannels(old, news map[string]string) (added, removed []string) {
for key, newHash := range news {
if oldHash, ok := old[key]; ok {
if newHash != oldHash {
removed = append(removed, key)
added = append(added, key)
}
} else {
added = append(added, key)
}
}
for key := range old {
if _, ok := news[key]; !ok {
removed = append(removed, key)
}
}
return added, removed
}
func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) {
result := &config.ChannelsConfig{}
ch := cfg.Channels
// should not be error
marshal, _ := json.Marshal(ch)
var channelConfig map[string]map[string]any
_ = json.Unmarshal(marshal, &channelConfig)
temp := make(map[string]map[string]any, 0)
for key, value := range channelConfig {
found := false
for _, s := range list {
if key == s {
found = true
break
}
}
if !found || !value["enabled"].(bool) {
continue
}
temp[key] = value
}
marshal, err := json.Marshal(temp)
if err != nil {
logger.Errorf("marshal error: %v", err)
return nil, err
}
err = json.Unmarshal(marshal, result)
if err != nil {
logger.Errorf("unmarshal error: %v", err)
return nil, err
}
return result, nil
}

View file

@ -0,0 +1,51 @@
package channels
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func TestToChannelHashes(t *testing.T) {
logger.SetLevel(logger.DEBUG)
cfg := config.DefaultConfig()
results := toChannelHashes(cfg)
assert.Equal(t, 0, len(results))
logger.Debugf("results: %v", results)
cfg2 := config.DefaultConfig()
cfg2.Channels.DingTalk.Enabled = true
results2 := toChannelHashes(cfg2)
assert.Equal(t, 1, len(results2))
logger.Debugf("results2: %v", results2)
added, removed := compareChannels(results, results2)
assert.EqualValues(t, []string{"dingtalk"}, added)
assert.EqualValues(t, []string(nil), removed)
cfg3 := config.DefaultConfig()
cfg3.Channels.Telegram.Enabled = true
results3 := toChannelHashes(cfg3)
assert.Equal(t, 1, len(results3))
logger.Debugf("results3: %v", results3)
added, removed = compareChannels(results2, results3)
assert.EqualValues(t, []string{"dingtalk"}, removed)
assert.EqualValues(t, []string{"telegram"}, added)
cfg3.Channels.Telegram.Token = "114314"
results4 := toChannelHashes(cfg3)
assert.Equal(t, 1, len(results4))
logger.Debugf("results4: %v", results4)
added, removed = compareChannels(results3, results4)
assert.EqualValues(t, []string{"telegram"}, removed)
assert.EqualValues(t, []string{"telegram"}, added)
cc, err := toChannelConfig(cfg3, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "114314", cc.Telegram.Token)
assert.Equal(t, true, cc.Telegram.Enabled)
cc, err = toChannelConfig(cfg2, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "", cc.Telegram.Token)
assert.Equal(t, false, cc.Telegram.Enabled)
}

View file

@ -511,6 +511,43 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
}
}
func TestInvokeTypingStop_CallsRegisteredStop(t *testing.T) {
m := newTestManager()
var stopCalled bool
m.RecordTypingStop("telegram", "chat123", func() {
stopCalled = true
})
m.InvokeTypingStop("telegram", "chat123")
if !stopCalled {
t.Fatal("expected typing stop func to be called")
}
}
func TestInvokeTypingStop_NoOpWhenNoEntry(t *testing.T) {
m := newTestManager()
// Should not panic
m.InvokeTypingStop("telegram", "nonexistent")
}
func TestInvokeTypingStop_Idempotent(t *testing.T) {
m := newTestManager()
var callCount int
m.RecordTypingStop("telegram", "chat123", func() {
callCount++
})
m.InvokeTypingStop("telegram", "chat123")
m.InvokeTypingStop("telegram", "chat123") // Second call: entry already removed, no-op
if callCount != 1 {
t.Fatalf("expected stop to be called once, got %d", callCount)
}
}
func TestPreSend_TypingStopCalled(t *testing.T) {
m := newTestManager()
var stopCalled bool

319
pkg/channels/pico/client.go Normal file
View file

@ -0,0 +1,319 @@
package pico
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
// PicoClientChannel connects to a remote Pico Protocol WebSocket server.
type PicoClientChannel struct {
*channels.BaseChannel
config config.PicoClientConfig
conn *picoConn
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// NewPicoClientChannel creates a new Pico Protocol client channel.
func NewPicoClientChannel(
cfg config.PicoClientConfig,
messageBus *bus.MessageBus,
) (*PicoClientChannel, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("pico_client url is required")
}
base := channels.NewBaseChannel("pico_client", cfg, messageBus, cfg.AllowFrom)
return &PicoClientChannel{
BaseChannel: base,
config: cfg,
}, nil
}
// Start dials the remote server and begins reading.
func (c *PicoClientChannel) Start(ctx context.Context) error {
logger.InfoC("pico_client", "Starting Pico Client channel")
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.dial(); err != nil {
c.cancel()
return fmt.Errorf("pico_client initial connect: %w", err)
}
c.SetRunning(true)
go c.reconnectLoop()
logger.InfoCF("pico_client", "Connected", map[string]any{"url": c.config.URL})
return nil
}
// Stop closes the connection.
func (c *PicoClientChannel) Stop(ctx context.Context) error {
logger.InfoC("pico_client", "Stopping Pico Client channel")
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
}
c.mu.Lock()
if c.conn != nil {
c.conn.close()
}
c.mu.Unlock()
logger.InfoC("pico_client", "Pico Client channel stopped")
return nil
}
func (c *PicoClientChannel) dial() error {
header := http.Header{}
if c.config.Token != "" {
header.Set("Authorization", "Bearer "+c.config.Token)
}
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err != nil {
return err
}
connCtx, connCancel := context.WithCancel(c.ctx)
pc := &picoConn{
id: uuid.New().String(),
conn: ws,
sessionID: c.config.SessionID,
cancel: connCancel,
}
if pc.sessionID == "" {
pc.sessionID = uuid.New().String()
}
c.mu.Lock()
c.conn = pc
c.mu.Unlock()
go c.readLoop(connCtx, pc)
return nil
}
// reconnectLoop re-dials when the connection drops.
func (c *PicoClientChannel) reconnectLoop() {
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
pc := c.conn
c.mu.Unlock()
if pc == nil || pc.closed.Load() {
backoff := 5 * time.Second
logger.InfoC("pico_client", "Reconnecting...")
if err := c.dial(); err != nil {
logger.WarnCF("pico_client", "Reconnect failed", map[string]any{
"error": err.Error(),
})
select {
case <-c.ctx.Done():
return
case <-time.After(backoff):
}
continue
}
logger.InfoC("pico_client", "Reconnected")
}
select {
case <-c.ctx.Done():
return
case <-time.After(1 * time.Second):
}
}
}
func (c *PicoClientChannel) readLoop(connCtx context.Context, pc *picoConn) {
defer pc.close()
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
if readTimeout <= 0 {
readTimeout = 60 * time.Second
}
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
pc.conn.SetPongHandler(func(string) error {
return pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
})
pingInterval := time.Duration(c.config.PingInterval) * time.Second
if pingInterval <= 0 {
pingInterval = 30 * time.Second
}
go c.pingLoop(connCtx, pc, pingInterval)
for {
select {
case <-connCtx.Done():
return
default:
}
_, raw, err := pc.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseGoingAway,
websocket.CloseNormalClosure,
) {
logger.DebugCF("pico_client", "Read error", map[string]any{
"error": err.Error(),
})
}
return
}
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
var msg PicoMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
c.handleInbound(pc, msg)
}
}
func (c *PicoClientChannel) pingLoop(connCtx context.Context, pc *picoConn, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-connCtx.Done():
return
case <-ticker.C:
if pc.closed.Load() {
return
}
pc.writeMu.Lock()
err := pc.conn.WriteMessage(websocket.PingMessage, nil)
pc.writeMu.Unlock()
if err != nil {
return
}
}
}
}
// handleInbound processes messages from the remote server.
// In client mode the server sends message.create (responses) and the client
// sends message.send (user input). We treat message.create from the server
// as inbound user messages to feed into the agent loop.
func (c *PicoClientChannel) handleInbound(pc *picoConn, msg PicoMessage) {
switch msg.Type {
case TypePong:
// response to our ping, ignore
case TypeMessageCreate:
// Server sent us a message — treat as inbound
c.handleServerMessage(pc, msg)
default:
logger.DebugCF("pico_client", "Ignoring message type", map[string]any{
"type": msg.Type,
})
}
}
func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
content, _ := msg.Payload["content"].(string)
if strings.TrimSpace(content) == "" {
return
}
sessionID := msg.SessionID
if sessionID == "" {
sessionID = pc.sessionID
}
chatID := "pico_client:" + sessionID
senderID := "pico-remote"
peer := bus.Peer{Kind: "direct", ID: chatID}
sender := bus.SenderInfo{
Platform: "pico_client",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("pico_client", senderID),
}
if !c.IsAllowedSender(sender) {
return
}
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, map[string]string{
"platform": "pico_client",
"session_id": sessionID,
}, sender)
}
// Send sends a message to the remote server.
func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
c.mu.Lock()
pc := c.conn
c.mu.Unlock()
if pc == nil || pc.closed.Load() {
return channels.ErrSendFailed
}
outMsg := newMessage(TypeMessageSend, map[string]any{
"content": msg.Content,
})
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
return pc.writeJSON(outMsg)
}
// StartTyping implements channels.TypingCapable.
func (c *PicoClientChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
c.mu.Lock()
pc := c.conn
c.mu.Unlock()
if pc == nil || pc.closed.Load() {
return func() {}, nil
}
startMsg := newMessage(TypeTypingStart, nil)
startMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:")
if err := pc.writeJSON(startMsg); err != nil {
return func() {}, err
}
return func() {
c.mu.Lock()
currentPC := c.conn
c.mu.Unlock()
if currentPC == nil {
return
}
stopMsg := newMessage(TypeTypingStop, nil)
stopMsg.SessionID = strings.TrimPrefix(chatID, "pico_client:")
currentPC.writeJSON(stopMsg)
}, nil
}

View file

@ -0,0 +1,264 @@
package pico
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewPicoClientChannel_MissingURL(t *testing.T) {
_, err := NewPicoClientChannel(config.PicoClientConfig{}, bus.NewMessageBus())
if err == nil {
t.Fatal("expected error for missing URL")
}
if !strings.Contains(err.Error(), "url is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewPicoClientChannel_OK(t *testing.T) {
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: "ws://localhost:9999/ws",
}, bus.NewMessageBus())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Name() != "pico_client" {
t.Fatalf("name = %q, want pico_client", ch.Name())
}
}
func TestSend_NotRunning(t *testing.T) {
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: "ws://localhost:9999/ws",
}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"})
if !errors.Is(err, channels.ErrNotRunning) {
t.Fatalf("expected ErrNotRunning, got %v", err)
}
}
// testServer starts a WS server that echoes message.send back as message.create.
func testServer(t *testing.T, token string) *httptest.Server {
t.Helper()
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token != "" {
auth := r.Header.Get("Authorization")
if auth != "Bearer "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var msg PicoMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg.Type == TypeMessageSend {
reply := newMessage(TypeMessageCreate, msg.Payload)
reply.SessionID = msg.SessionID
if err := conn.WriteJSON(reply); err != nil {
return
}
}
}
}))
}
func wsURL(httpURL string) string {
return "ws" + strings.TrimPrefix(httpURL, "http")
}
func TestClientChannel_ConnectAndSend(t *testing.T) {
srv := testServer(t, "test-token")
defer srv.Close()
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
Token: "test-token",
SessionID: "sess-1",
PingInterval: 60,
ReadTimeout: 10,
}, mb)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = ch.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer ch.Stop(ctx)
// Send a message
err = ch.Send(ctx, bus.OutboundMessage{
ChatID: "pico_client:sess-1",
Content: "hello",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
func TestClientChannel_AuthFailure(t *testing.T) {
srv := testServer(t, "correct-token")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
Token: "wrong-token",
}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err = ch.Start(ctx)
if err == nil {
ch.Stop(ctx)
t.Fatal("expected auth failure")
}
}
func TestClientChannel_ReceivesServerMessage(t *testing.T) {
srv := testServer(t, "")
defer srv.Close()
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
SessionID: "sess-echo",
ReadTimeout: 10,
}, mb)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = ch.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer ch.Stop(ctx)
// Send a message; the echo server replies with message.create
err = ch.Send(ctx, bus.OutboundMessage{
ChatID: "pico_client:sess-echo",
Content: "ping",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
// The echoed message.create is processed by handleServerMessage which
// calls HandleMessage → PublishInbound. Consume it from the bus.
select {
case msg := <-mb.InboundChan():
if msg.Content != "ping" {
t.Fatalf("received = %q, want %q", msg.Content, "ping")
}
case <-ctx.Done():
t.Fatal("timed out waiting for echoed message")
}
}
func TestClientChannel_StartTyping(t *testing.T) {
srv := testServer(t, "")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
SessionID: "sess-type",
ReadTimeout: 10,
}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = ch.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
defer ch.Stop(ctx)
stop, err := ch.StartTyping(ctx, "pico_client:sess-type")
if err != nil {
t.Fatalf("StartTyping: %v", err)
}
stop() // should not panic
}
func TestSend_ClosedConnection(t *testing.T) {
srv := testServer(t, "")
defer srv.Close()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
SessionID: "sess-close",
ReadTimeout: 10,
}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err = ch.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
// Force close the underlying connection
ch.mu.Lock()
ch.conn.close()
ch.mu.Unlock()
err = ch.Send(ctx, bus.OutboundMessage{
ChatID: "pico_client:sess-close",
Content: "should fail",
})
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("expected ErrSendFailed, got %v", err)
}
ch.Stop(ctx)
}

View file

@ -10,4 +10,7 @@ func init() {
channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoChannel(cfg.Channels.Pico, b)
})
channels.RegisterFactory("pico_client", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoClientChannel(cfg.Channels.PicoClient, b)
})
}

View file

@ -27,6 +27,7 @@ type picoConn struct {
sessionID string
writeMu sync.Mutex
closed atomic.Bool
cancel context.CancelFunc // cancels per-connection goroutines (e.g. pingLoop)
}
// writeJSON sends a JSON message to the connection with write locking.
@ -42,6 +43,9 @@ func (pc *picoConn) writeJSON(v any) error {
// close closes the connection.
func (pc *picoConn) close() {
if pc.closed.CompareAndSwap(false, true) {
if pc.cancel != nil {
pc.cancel()
}
pc.conn.Close()
}
}

View file

@ -0,0 +1,41 @@
package qq
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/logger"
)
// botGoLogger preserves useful SDK info logs while demoting noisy heartbeat
// traffic to DEBUG so long-running QQ sessions do not spam the console.
type botGoLogger struct {
*logger.Logger
}
func newBotGoLogger(component string) *botGoLogger {
return &botGoLogger{Logger: logger.NewLogger(component)}
}
func (b *botGoLogger) Info(v ...any) {
message := fmt.Sprint(v...)
if shouldDemoteBotGoInfo(message) {
b.Logger.Debug(message)
return
}
b.Logger.Info(message)
}
func (b *botGoLogger) Infof(format string, v ...any) {
message := fmt.Sprintf(format, v...)
if shouldDemoteBotGoInfo(message) {
b.Logger.Debug(message)
return
}
b.Logger.Info(message)
}
func shouldDemoteBotGoInfo(message string) bool {
return strings.Contains(message, " write Heartbeat message") ||
strings.Contains(message, " receive HeartbeatAck message")
}

View file

@ -2,7 +2,15 @@ package qq
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
@ -10,9 +18,10 @@ import (
"time"
"github.com/tencent-connect/botgo"
"github.com/tencent-connect/botgo/constant"
"github.com/tencent-connect/botgo/dto"
"github.com/tencent-connect/botgo/event"
"github.com/tencent-connect/botgo/openapi"
"github.com/tencent-connect/botgo/openapi/options"
"github.com/tencent-connect/botgo/token"
"golang.org/x/oauth2"
@ -21,6 +30,8 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
@ -29,16 +40,29 @@ const (
dedupMaxSize = 10000 // hard cap on dedup map entries
typingResend = 8 * time.Second
typingSeconds = 10
bytesPerMiB = 1024 * 1024
)
type qqAPI interface {
WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error)
PostGroupMessage(
ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option,
) (*dto.Message, error)
PostC2CMessage(
ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option,
) (*dto.Message, error)
Transport(ctx context.Context, method, url string, body any) ([]byte, error)
}
type QQChannel struct {
*channels.BaseChannel
config config.QQConfig
api openapi.OpenAPI
api qqAPI
tokenSource oauth2.TokenSource
ctx context.Context
cancel context.CancelFunc
sessionManager botgo.SessionManager
downloadFn func(urlStr, filename string) string
// Chat routing: track whether a chatID is group or direct.
chatType sync.Map // chatID → "group" | "direct"
@ -78,7 +102,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
botgo.SetLogger(logger.NewLogger("botgo"))
botgo.SetLogger(newBotGoLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.
@ -199,20 +223,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
msgToCreate.Content = ""
}
// Attach passive reply msg_id and msg_seq if available.
if v, ok := c.lastMsgID.Load(msg.ChatID); ok {
if msgID, ok := v.(string); ok && msgID != "" {
msgToCreate.MsgID = msgID
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok {
if counter, ok := counterVal.(*atomic.Uint64); ok {
seq := counter.Add(1)
msgToCreate.MsgSeq = uint32(seq)
}
}
}
}
c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate)
// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
if chatKind == "group" {
@ -305,9 +316,9 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err
}
// SendMedia implements the channels.MediaSender interface.
// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported.
// If part.Ref is already an http(s) URL it is used directly; otherwise we try
// the media store, and skip with a warning if the resolved path is not an HTTP URL.
// QQ group/C2C media sending is a two-step flow:
// 1. Upload media to /files using a remote URL or base64-encoded local bytes.
// 2. Send a msg_type=7 message using the returned file_info.
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
@ -316,69 +327,24 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
chatKind := c.getChatKind(msg.ChatID)
for _, part := range msg.Parts {
// If the ref is already an HTTP(S) URL, use it directly.
mediaURL := part.Ref
if !isHTTPURL(mediaURL) {
// Try resolving through media store.
store := c.GetMediaStore()
if store == nil {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{
"ref": part.Ref,
})
continue
fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)
if err != nil {
logger.ErrorCF("qq", "Failed to upload media", map[string]any{
"type": part.Type,
"chat_id": msg.ChatID,
"error": err.Error(),
})
if errors.Is(err, channels.ErrSendFailed) {
return err
}
resolved, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
if !isHTTPURL(resolved) {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{
"ref": part.Ref,
"resolved": resolved,
})
continue
}
mediaURL = resolved
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
var fileType uint64
switch part.Type {
case "image":
fileType = 1
case "video":
fileType = 2
case "audio":
fileType = 3
default:
fileType = 4 // file
}
richMedia := &dto.RichMediaMessage{
FileType: fileType,
URL: mediaURL,
SrvSendMsg: true,
}
var sendErr error
if chatKind == "group" {
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia)
} else {
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia)
}
if sendErr != nil {
if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type,
"chat_id": msg.ChatID,
"error": sendErr.Error(),
"error": err.Error(),
})
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
@ -387,6 +353,161 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
return nil
}
type qqMediaUpload struct {
FileType uint64 `json:"file_type"`
URL string `json:"url,omitempty"`
FileData string `json:"file_data,omitempty"`
SrvSendMsg bool `json:"srv_send_msg,omitempty"`
}
func (c *QQChannel) uploadMedia(
ctx context.Context,
chatKind, chatID string,
part bus.MediaPart,
) ([]byte, error) {
payload, err := c.buildMediaUpload(part)
if err != nil {
return nil, err
}
body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload)
if err != nil {
return nil, err
}
var uploaded dto.Message
if err := json.Unmarshal(body, &uploaded); err != nil {
return nil, fmt.Errorf("qq decode media upload response: %w", err)
}
if len(uploaded.FileInfo) == 0 {
return nil, fmt.Errorf("qq upload media: missing file_info")
}
return uploaded.FileInfo, nil
}
func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {
payload := &qqMediaUpload{
FileType: qqFileType(part.Type),
}
mediaRef := part.Ref
if isHTTPURL(mediaRef) {
payload.URL = mediaRef
return payload, nil
}
store := c.GetMediaStore()
if store == nil {
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
resolved, err := store.Resolve(part.Ref)
if err != nil {
return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
}
if isHTTPURL(resolved) {
payload.URL = resolved
return payload, nil
}
if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
info, statErr := os.Stat(resolved)
if statErr != nil {
return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed)
}
if info.Size() > limitBytes {
return nil, fmt.Errorf(
"qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w",
resolved,
info.Size(),
limitBytes,
channels.ErrSendFailed,
)
}
}
data, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed)
}
payload.FileData = base64.StdEncoding.EncodeToString(data)
return payload, nil
}
func (c *QQChannel) sendUploadedMedia(
ctx context.Context,
chatKind, chatID string,
part bus.MediaPart,
fileInfo []byte,
) error {
msg := &dto.MessageToCreate{
Content: part.Caption,
MsgType: dto.RichMediaMsg,
Media: &dto.MediaInfo{
FileInfo: fileInfo,
},
}
c.applyPassiveReplyMetadata(chatID, msg)
if chatKind == "group" && msg.Content != "" {
msg.Content = sanitizeURLs(msg.Content)
}
if chatKind == "group" {
_, err := c.api.PostGroupMessage(ctx, chatID, msg)
return err
}
_, err := c.api.PostC2CMessage(ctx, chatID, msg)
return err
}
func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) {
if v, ok := c.lastMsgID.Load(chatID); ok {
if msgID, ok := v.(string); ok && msgID != "" {
msg.MsgID = msgID
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(chatID); ok {
if counter, ok := counterVal.(*atomic.Uint64); ok {
seq := counter.Add(1)
msg.MsgSeq = uint32(seq)
}
}
}
}
}
func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string {
base := constant.APIDomain
if chatKind == "group" {
return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID)
}
return fmt.Sprintf("%s/v2/users/%s/files", base, chatID)
}
func qqFileType(partType string) uint64 {
switch partType {
case "image":
return 1
case "video":
return 2
case "audio":
return 3
default:
return 4
}
}
func (c *QQChannel) maxBase64FileSizeBytes() int64 {
if c.config.MaxBase64FileSizeMiB <= 0 {
return 0
}
return c.config.MaxBase64FileSizeMiB * bytesPerMiB
}
// handleC2CMessage handles QQ private messages.
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
@ -404,16 +525,30 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
// extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
if !c.IsAllowedSender(sender) {
return nil
}
content := strings.TrimSpace(data.Content)
mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments)
for _, note := range attachmentNotes {
content = appendContent(content, note)
}
if content == "" && len(mediaPaths) == 0 {
logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring")
return nil
}
logger.InfoCF("qq", "Received C2C message", map[string]any{
"sender": senderID,
"length": len(content),
"sender": senderID,
"length": len(content),
"media_count": len(mediaPaths),
})
// Store chat routing context.
@ -427,23 +562,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"account_id": senderID,
}
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
if !c.IsAllowedSender(sender) {
return nil
}
c.HandleMessage(c.ctx,
bus.Peer{Kind: "direct", ID: senderID},
data.ID,
senderID,
senderID,
content,
[]string{},
mediaPaths,
metadata,
sender,
)
@ -469,24 +594,38 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
// extract message content (remove @ bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
if !c.IsAllowedSender(sender) {
return nil
}
// GroupAT event means bot is always mentioned; apply group trigger filtering
content := strings.TrimSpace(data.Content)
mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments)
for _, note := range attachmentNotes {
content = appendContent(content, note)
}
// GroupAT event means bot is always mentioned; apply group trigger filtering.
respond, cleaned := c.ShouldRespondInGroup(true, content)
if !respond {
return nil
}
content = cleaned
if content == "" && len(mediaPaths) == 0 {
logger.DebugC("qq", "Received empty group message with no attachments, ignoring")
return nil
}
logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
"sender": senderID,
"group": data.GroupID,
"length": len(content),
"media_count": len(mediaPaths),
})
// Store chat routing context using GroupID as chatID.
@ -501,23 +640,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"group_id": data.GroupID,
}
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
if !c.IsAllowedSender(sender) {
return nil
}
c.HandleMessage(c.ctx,
bus.Peer{Kind: "group", ID: data.GroupID},
data.ID,
senderID,
data.GroupID,
content,
[]string{},
mediaPaths,
metadata,
sender,
)
@ -526,6 +655,157 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
func (c *QQChannel) extractInboundAttachments(
chatID, messageID string,
attachments []*dto.MessageAttachment,
) ([]string, []string) {
if len(attachments) == 0 {
return nil, nil
}
scope := channels.BuildMediaScope("qq", chatID, messageID)
mediaPaths := make([]string, 0, len(attachments))
notes := make([]string, 0, len(attachments))
storeMedia := func(localPath string, attachment *dto.MessageAttachment) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: qqAttachmentFilename(attachment),
ContentType: attachment.ContentType,
Source: "qq",
}, scope)
if err == nil {
return ref
}
}
return localPath
}
for _, attachment := range attachments {
if attachment == nil {
continue
}
filename := qqAttachmentFilename(attachment)
if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
} else if attachment.URL != "" {
mediaPaths = append(mediaPaths, attachment.URL)
}
notes = append(notes, qqAttachmentNote(attachment))
}
return mediaPaths, notes
}
func (c *QQChannel) downloadAttachment(urlStr, filename string) string {
if urlStr == "" {
return ""
}
if c.downloadFn != nil {
return c.downloadFn(urlStr, filename)
}
return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
LoggerPrefix: "qq",
ExtraHeaders: c.downloadHeaders(),
})
}
func (c *QQChannel) downloadHeaders() map[string]string {
headers := map[string]string{}
if c.config.AppID != "" {
headers["X-Union-Appid"] = c.config.AppID
}
if c.tokenSource != nil {
if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" {
auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken)
if auth != "" {
headers["Authorization"] = auth
}
}
}
if len(headers) == 0 {
return nil
}
return headers
}
func qqAttachmentFilename(attachment *dto.MessageAttachment) string {
if attachment == nil {
return "attachment"
}
if attachment.FileName != "" {
return attachment.FileName
}
if attachment.URL != "" {
if parsed, err := url.Parse(attachment.URL); err == nil {
if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
return base
}
}
}
switch qqAttachmentKind(attachment) {
case "image":
return "image"
case "audio":
return "audio"
case "video":
return "video"
default:
return "attachment"
}
}
func qqAttachmentKind(attachment *dto.MessageAttachment) string {
if attachment == nil {
return "file"
}
contentType := strings.ToLower(attachment.ContentType)
filename := strings.ToLower(attachment.FileName)
switch {
case strings.HasPrefix(contentType, "image/"):
return "image"
case strings.HasPrefix(contentType, "video/"):
return "video"
case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg":
return "audio"
}
switch filepath.Ext(filename) {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
return "image"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk":
return "audio"
default:
return "file"
}
}
func qqAttachmentNote(attachment *dto.MessageAttachment) string {
filename := qqAttachmentFilename(attachment)
switch qqAttachmentKind(attachment) {
case "image":
return fmt.Sprintf("[image: %s]", filename)
case "audio":
return fmt.Sprintf("[audio: %s]", filename)
case "video":
return fmt.Sprintf("[video: %s]", filename)
default:
return fmt.Sprintf("[file: %s]", filename)
}
}
// isDuplicate checks whether a message has been seen within the TTL window.
// It also enforces a hard cap on map size by evicting oldest entries.
func (c *QQChannel) isDuplicate(messageID string) bool {
@ -587,6 +867,16 @@ func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func appendContent(content, suffix string) string {
if suffix == "" {
return content
}
if content == "" {
return suffix
}
return content + "\n" + suffix
}
// urlPattern matches URLs with explicit http(s):// scheme.
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
// like version numbers (e.g., "1.2.3") or domain-like fragments.

View file

@ -2,13 +2,22 @@ package qq
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/tencent-connect/botgo/dto"
"github.com/tencent-connect/botgo/openapi/options"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
@ -50,3 +59,438 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
}
}
}
func TestHandleC2CMessage_AttachmentOnlyPublishesMedia(t *testing.T) {
messageBus := bus.NewMessageBus()
store := media.NewFileMediaStore()
localPath := writeTempFile(t, t.TempDir(), "image.png", []byte("fake-image"))
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
downloadFn: func(urlStr, filename string) string {
if filename != "image.png" {
t.Fatalf("download filename = %q, want image.png", filename)
}
return localPath
},
}
ch.SetMediaStore(store)
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
ID: "msg-attachment",
Content: "",
Author: &dto.User{
ID: "7750283E123456",
},
Attachments: []*dto.MessageAttachment{{
URL: "https://example.com/image.png",
FileName: "image.png",
ContentType: "image/png",
}},
})
if err != nil {
t.Fatalf("handleC2CMessage() error = %v", err)
}
inbound := waitInboundMessage(t, messageBus)
if inbound.Content != "[image: image.png]" {
t.Fatalf("inbound.Content = %q", inbound.Content)
}
if len(inbound.Media) != 1 {
t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media))
}
if !strings.HasPrefix(inbound.Media[0], "media://") {
t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0])
}
_, meta, err := store.ResolveWithMeta(inbound.Media[0])
if err != nil {
t.Fatalf("ResolveWithMeta() error = %v", err)
}
if meta.Filename != "image.png" {
t.Fatalf("meta.Filename = %q, want image.png", meta.Filename)
}
if meta.ContentType != "image/png" {
t.Fatalf("meta.ContentType = %q, want image/png", meta.ContentType)
}
}
func TestHandleGroupATMessage_AttachmentOnlyPublishesMedia(t *testing.T) {
messageBus := bus.NewMessageBus()
store := media.NewFileMediaStore()
localPath := writeTempFile(t, t.TempDir(), "report.pdf", []byte("fake-pdf"))
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
downloadFn: func(urlStr, filename string) string {
if filename != "report.pdf" {
t.Fatalf("download filename = %q, want report.pdf", filename)
}
return localPath
},
}
ch.SetMediaStore(store)
err := ch.handleGroupATMessage()(nil, &dto.WSGroupATMessageData{
ID: "group-attachment",
GroupID: "group-1",
Content: "",
Author: &dto.User{
ID: "7750283E123456",
},
Attachments: []*dto.MessageAttachment{{
URL: "https://example.com/report.pdf",
FileName: "report.pdf",
ContentType: "application/pdf",
}},
})
if err != nil {
t.Fatalf("handleGroupATMessage() error = %v", err)
}
inbound := waitInboundMessage(t, messageBus)
if inbound.Content != "[file: report.pdf]" {
t.Fatalf("inbound.Content = %q", inbound.Content)
}
if len(inbound.Media) != 1 {
t.Fatalf("len(inbound.Media) = %d, want 1", len(inbound.Media))
}
if !strings.HasPrefix(inbound.Media[0], "media://") {
t.Fatalf("inbound.Media[0] = %q, want media:// ref", inbound.Media[0])
}
if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-1" {
t.Fatalf("inbound.Peer = %+v, want group/group-1", inbound.Peer)
}
}
func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {
messageBus := bus.NewMessageBus()
store := media.NewFileMediaStore()
tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-*.png")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
defer tmpFile.Close()
content := []byte("local-image-data")
if _, writeErr := tmpFile.Write(content); writeErr != nil {
t.Fatalf("Write() error = %v", writeErr)
}
ref, err := store.Store(tmpFile.Name(), media.MediaMeta{
Filename: "reply.png",
ContentType: "image/png",
}, "qq:test")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
api := &fakeQQAPI{
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("uploaded-file-info")}),
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
}
ch.SetRunning(true)
ch.SetMediaStore(store)
ch.chatType.Store("group-1", "group")
ch.lastMsgID.Store("group-1", "msg-1")
ch.msgSeqCounters.Store("group-1", new(atomic.Uint64))
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1",
Parts: []bus.MediaPart{{
Type: "image",
Ref: ref,
Caption: "see https://example.com/image",
}},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
if len(api.transportCalls) != 1 {
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
}
upload := api.transportCalls[0]
if upload.method != "POST" {
t.Fatalf("upload method = %q, want POST", upload.method)
}
if upload.url != "https://api.sgroup.qq.com/v2/groups/group-1/files" {
t.Fatalf("upload url = %q", upload.url)
}
if upload.body.URL != "" {
t.Fatalf("upload URL = %q, want empty", upload.body.URL)
}
wantBase64 := base64.StdEncoding.EncodeToString(content)
if upload.body.FileData != wantBase64 {
t.Fatalf("upload file_data = %q, want %q", upload.body.FileData, wantBase64)
}
if upload.body.FileType != 1 {
t.Fatalf("upload file_type = %d, want 1", upload.body.FileType)
}
if len(api.groupMessages) != 1 {
t.Fatalf("groupMessages = %d, want 1", len(api.groupMessages))
}
msg, ok := api.groupMessages[0].(*dto.MessageToCreate)
if !ok {
t.Fatalf("groupMessages[0] type = %T, want *dto.MessageToCreate", api.groupMessages[0])
}
if msg.MsgType != dto.RichMediaMsg {
t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg)
}
if msg.MsgID != "msg-1" {
t.Fatalf("msg.MsgID = %q, want msg-1", msg.MsgID)
}
if msg.MsgSeq != 1 {
t.Fatalf("msg.MsgSeq = %d, want 1", msg.MsgSeq)
}
if msg.Content != "see https://example。com/image" {
t.Fatalf("msg.Content = %q", msg.Content)
}
if msg.Media == nil || string(msg.Media.FileInfo) != "uploaded-file-info" {
t.Fatalf("msg.Media.FileInfo = %q, want uploaded-file-info", string(msg.Media.FileInfo))
}
}
func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
messageBus := bus.NewMessageBus()
api := &fakeQQAPI{
transportResp: mustJSON(t, dto.Message{FileInfo: []byte("remote-file-info")}),
}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
}
ch.SetRunning(true)
ch.chatType.Store("user-1", "direct")
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "user-1",
Parts: []bus.MediaPart{{
Type: "file",
Ref: "https://cdn.example.com/report.pdf",
}},
})
if err != nil {
t.Fatalf("SendMedia() error = %v", err)
}
if len(api.transportCalls) != 1 {
t.Fatalf("transportCalls = %d, want 1", len(api.transportCalls))
}
upload := api.transportCalls[0]
if upload.url != "https://api.sgroup.qq.com/v2/users/user-1/files" {
t.Fatalf("upload url = %q", upload.url)
}
if upload.body.URL != "https://cdn.example.com/report.pdf" {
t.Fatalf("upload URL = %q", upload.body.URL)
}
if upload.body.FileData != "" {
t.Fatalf("upload file_data = %q, want empty", upload.body.FileData)
}
if upload.body.FileType != 4 {
t.Fatalf("upload file_type = %d, want 4", upload.body.FileType)
}
if len(api.c2cMessages) != 1 {
t.Fatalf("c2cMessages = %d, want 1", len(api.c2cMessages))
}
msg, ok := api.c2cMessages[0].(*dto.MessageToCreate)
if !ok {
t.Fatalf("c2cMessages[0] type = %T, want *dto.MessageToCreate", api.c2cMessages[0])
}
if msg.MsgType != dto.RichMediaMsg {
t.Fatalf("msg.MsgType = %d, want %d", msg.MsgType, dto.RichMediaMsg)
}
if msg.Media == nil || string(msg.Media.FileInfo) != "remote-file-info" {
t.Fatalf("msg.Media.FileInfo = %q, want remote-file-info", string(msg.Media.FileInfo))
}
}
func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
api: &fakeQQAPI{},
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
}
ch.SetRunning(true)
ch.chatType.Store("group-1", "group")
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1",
Parts: []bus.MediaPart{{
Type: "image",
Ref: "media://missing",
}},
})
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err)
}
}
func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testing.T) {
messageBus := bus.NewMessageBus()
store := media.NewFileMediaStore()
tmpFile, err := os.CreateTemp(t.TempDir(), "qq-media-too-large-*.bin")
if err != nil {
t.Fatalf("CreateTemp() error = %v", err)
}
defer tmpFile.Close()
content := make([]byte, bytesPerMiB+1)
if _, writeErr := tmpFile.Write(content); writeErr != nil {
t.Fatalf("Write() error = %v", writeErr)
}
ref, err := store.Store(tmpFile.Name(), media.MediaMeta{
Filename: "large.bin",
ContentType: "application/octet-stream",
}, "qq:test")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
api := &fakeQQAPI{}
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
config: config.QQConfig{
MaxBase64FileSizeMiB: 1,
},
api: api,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
}
ch.SetRunning(true)
ch.SetMediaStore(store)
ch.chatType.Store("group-1", "group")
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1",
Parts: []bus.MediaPart{{
Type: "file",
Ref: ref,
}},
})
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err)
}
if len(api.transportCalls) != 0 {
t.Fatalf("transportCalls = %d, want 0", len(api.transportCalls))
}
}
type fakeQQAPI struct {
transportResp []byte
transportErr error
groupErr error
c2cErr error
transportCalls []fakeTransportCall
groupMessages []dto.APIMessage
c2cMessages []dto.APIMessage
}
type fakeTransportCall struct {
method string
url string
body qqMediaUpload
}
func (f *fakeQQAPI) WS(
context.Context,
map[string]string,
string,
) (*dto.WebsocketAP, error) {
return nil, nil
}
func (f *fakeQQAPI) PostGroupMessage(
_ context.Context,
_ string,
msg dto.APIMessage,
_ ...options.Option,
) (*dto.Message, error) {
f.groupMessages = append(f.groupMessages, msg)
return &dto.Message{}, f.groupErr
}
func (f *fakeQQAPI) PostC2CMessage(
_ context.Context,
_ string,
msg dto.APIMessage,
_ ...options.Option,
) (*dto.Message, error) {
f.c2cMessages = append(f.c2cMessages, msg)
return &dto.Message{}, f.c2cErr
}
func (f *fakeQQAPI) Transport(_ context.Context, method, url string, body any) ([]byte, error) {
upload, ok := body.(*qqMediaUpload)
if !ok {
return nil, errors.New("unexpected transport body type")
}
f.transportCalls = append(f.transportCalls, fakeTransportCall{
method: method,
url: url,
body: *upload,
})
return f.transportResp, f.transportErr
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return b
}
func waitInboundMessage(t *testing.T, messageBus *bus.MessageBus) bus.InboundMessage {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for {
select {
case <-ctx.Done():
t.Fatal("timeout waiting for inbound message")
case inbound, ok := <-messageBus.InboundChan():
if !ok {
t.Fatal("expected inbound message")
}
return inbound
}
}
}
func writeTempFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := dir + "/" + name
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return path
}

View file

@ -305,10 +305,17 @@ func (c *TelegramChannel) sendChunk(
return nil
}
// maxTypingDuration limits how long the typing indicator can run.
// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.
// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.
const maxTypingDuration = 5 * time.Minute
// StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
// The goroutine also exits automatically after maxTypingDuration if cancel is
// never called (e.g. when the LLM fails or times out without publishing).
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
@ -322,12 +329,15 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
_ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx)
// Cap lifetime so the goroutine cannot run indefinitely if cancel is never called
maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration)
go func() {
defer maxCancel()
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
case <-maxCtx.Done():
return
case <-ticker.C:
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)

View file

@ -22,6 +22,10 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
// responseURLHTTPClient is a shared HTTP client for posting to WeCom response_url.
// Reusing it enables connection pooling across replies.
var responseURLHTTPClient = &http.Client{Timeout: 15 * time.Second}
// WeComAIBotChannel implements the Channel interface for WeCom AI Bot (企业微信智能机器人)
type WeComAIBotChannel struct {
*channels.BaseChannel
@ -134,13 +138,28 @@ type WeComAIBotEncryptedResponse struct {
Nonce string `json:"nonce"`
}
// NewWeComAIBotChannel creates a new WeCom AI Bot channel instance
// NewWeComAIBotChannel creates a WeCom AI Bot channel instance.
// If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel
// using the WebSocket long-connection API.
// Otherwise it returns the webhook-mode WeComAIBotChannel (requires Token +
// EncodingAESKey).
func NewWeComAIBotChannel(
cfg config.WeComAIBotConfig,
messageBus *bus.MessageBus,
) (*WeComAIBotChannel, error) {
) (channels.Channel, error) {
// WebSocket long-connection mode takes priority when BotID + Secret are set.
if cfg.BotID != "" && cfg.Secret != "" {
logger.InfoC("wecom_aibot", "BotID and Secret provided, using WebSocket mode")
return newWeComAIBotWSChannel(cfg, messageBus)
}
// Webhook (short-connection) mode.
if cfg.Token == "" || cfg.EncodingAESKey == "" {
return nil, fmt.Errorf("token and encoding_aes_key are required for WeCom AI Bot")
return nil, fmt.Errorf(
"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
"or (token + encoding_aes_key) for webhook mode")
}
if cfg.ProcessingMessage == "" {
cfg.ProcessingMessage = config.DefaultWeComAIBotProcessingMessage
}
base := channels.NewBaseChannel("wecom_aibot", cfg, messageBus, cfg.AllowFrom,
@ -693,7 +712,7 @@ func (c *WeComAIBotChannel) getStreamResponse(task *streamTask, timestamp, nonce
default:
if time.Now().After(task.Deadline) {
// Deadline reached: close the stream with a notice, then wait for agent via response_url.
content = "⏳ Processing, please wait. The results will be sent shortly."
content = c.config.ProcessingMessage
finish = true
closeStreamOnly = true
logger.InfoCF(
@ -782,8 +801,7 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
resp, err := responseURLHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("post to response_url failed: %w: %w", channels.ErrTemporary, err)
}
@ -793,7 +811,8 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
return nil
}
respBody, err := io.ReadAll(resp.Body)
const maxErrBody = 64 << 10 // 64 KB is more than enough for any error response
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
if err != nil {
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
}
@ -895,17 +914,80 @@ func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string,
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// generateStreamID generates a random stream ID
func (c *WeComAIBotChannel) generateStreamID() string {
// func (c *WeComAIBotChannel) downloadAndDecryptImage(
// ctx context.Context,
// imageURL string,
// ) ([]byte, error) {
// // Download image
// req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
// if err != nil {
// return nil, fmt.Errorf("failed to create request: %w", err)
// }
// client := &http.Client{
// Timeout: 15 * time.Second,
// }
// resp, err := client.Do(req)
// if err != nil {
// return nil, fmt.Errorf("failed to download image: %w", err)
// }
// defer resp.Body.Close()
// if resp.StatusCode != http.StatusOK {
// return nil, fmt.Errorf("download failed with status: %d", resp.StatusCode)
// }
// // Limit image download to 20 MB to prevent memory exhaustion
// const maxImageSize = 20 << 20 // 20 MB
// encryptedData, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize+1))
// if err != nil {
// return nil, fmt.Errorf("failed to read image data: %w", err)
// }
// if len(encryptedData) > maxImageSize {
// return nil, fmt.Errorf("image too large (exceeds %d MB)", maxImageSize>>20)
// }
// logger.DebugCF("wecom_aibot", "Image downloaded", map[string]any{
// "size": len(encryptedData),
// })
// // Decode AES key
// aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
// if err != nil {
// return nil, err
// }
// // Decrypt image (AES-CBC with IV = first 16 bytes of key, PKCS7 padding stripped)
// decryptedData, err := decryptAESCBC(aesKey, encryptedData)
// if err != nil {
// return nil, fmt.Errorf("failed to decrypt image: %w", err)
// }
// logger.DebugCF("wecom_aibot", "Image decrypted", map[string]any{
// "size": len(decryptedData),
// })
// return decryptedData, nil
// }
// generateRandomID generates a cryptographically random alphanumeric ID of
// length n. Used for stream IDs and WebSocket request IDs.
func generateRandomID(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 10)
b := make([]byte, n)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
b[i] = letters[n.Int64()]
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
b[i] = letters[num.Int64()]
}
return string(b)
}
// generateStreamID generates a random 10-character stream ID (webhook mode).
func (c *WeComAIBotChannel) generateStreamID() string {
return generateRandomID(10)
}
// cleanupLoop periodically cleans up old streaming tasks
func (c *WeComAIBotChannel) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)

View file

@ -2,13 +2,18 @@ package wecom
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewWeComAIBotChannel(t *testing.T) {
// ---- Webhook mode tests ----
func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
t.Run("success with valid config", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
@ -22,14 +27,16 @@ func TestNewWeComAIBotChannel(t *testing.T) {
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if ch == nil {
t.Fatal("Expected channel to be created")
}
if ch.Name() != "wecom_aibot" {
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
}
// Webhook mode must implement WebhookHandler.
if _, ok := ch.(channels.WebhookHandler); !ok {
t.Error("Webhook mode channel should implement WebhookHandler")
}
})
t.Run("error with missing token", func(t *testing.T) {
@ -37,10 +44,8 @@ func TestNewWeComAIBotChannel(t *testing.T) {
Enabled: true,
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
t.Fatal("Expected error for missing token, got nil")
}
@ -51,17 +56,15 @@ func TestNewWeComAIBotChannel(t *testing.T) {
Enabled: true,
Token: "test_token",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
t.Fatal("Expected error for missing encoding key, got nil")
}
})
}
func TestWeComAIBotChannelStartStop(t *testing.T) {
func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
@ -76,22 +79,18 @@ func TestWeComAIBotChannelStartStop(t *testing.T) {
ctx := context.Background()
// Test Start
if err := ch.Start(ctx); err != nil {
t.Fatalf("Failed to start channel: %v", err)
}
if !ch.IsRunning() {
t.Error("Expected channel to be running")
t.Error("Expected channel to be running after Start")
}
// Test Stop
if err := ch.Stop(ctx); err != nil {
t.Fatalf("Failed to stop channel: %v", err)
}
if ch.IsRunning() {
t.Error("Expected channel to be stopped")
t.Error("Expected channel to be stopped after Stop")
}
}
@ -102,13 +101,16 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
wh, ok := ch.(channels.WebhookHandler)
if !ok {
t.Fatal("Expected channel to implement WebhookHandler")
}
expectedPath := "/webhook/wecom-aibot"
if ch.WebhookPath() != expectedPath {
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
if wh.WebhookPath() != expectedPath {
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, wh.WebhookPath())
}
})
@ -120,12 +122,96 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
EncodingAESKey: "testkey1234567890123456789012345678901234567",
WebhookPath: customPath,
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
if ch.WebhookPath() != customPath {
t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
wh, ok := ch.(channels.WebhookHandler)
if !ok {
t.Fatal("Expected channel to implement WebhookHandler")
}
if wh.WebhookPath() != customPath {
t.Errorf("Expected webhook path '%s', got '%s'", customPath, wh.WebhookPath())
}
})
}
func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
validAESKey := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
t.Run("uses default processing message", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: validAESKey,
}
messageBus := bus.NewMessageBus()
channel, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
ch, ok := channel.(*WeComAIBotChannel)
if !ok {
t.Fatal("Expected webhook mode channel")
}
task := &streamTask{
StreamID: "stream-default",
ChatID: "chat-default",
Deadline: time.Now().Add(-time.Second),
}
ch.streamTasks[task.StreamID] = task
ch.chatTasks[task.ChatID] = []*streamTask{task}
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
if !resp.Stream.Finish {
t.Fatal("Expected finished stream response after deadline")
}
if resp.Stream.Content != config.DefaultWeComAIBotProcessingMessage {
t.Fatalf("Expected default processing message %q, got %q",
config.DefaultWeComAIBotProcessingMessage, resp.Stream.Content)
}
if !task.StreamClosed {
t.Fatal("Expected task stream to be marked closed")
}
if _, ok := ch.streamTasks[task.StreamID]; ok {
t.Fatal("Expected closed stream task to be removed from streamTasks")
}
if len(ch.chatTasks[task.ChatID]) != 1 {
t.Fatalf("Expected task to remain queued for response_url delivery, got %d entries",
len(ch.chatTasks[task.ChatID]))
}
})
t.Run("uses custom processing message", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: validAESKey,
ProcessingMessage: "Please wait a moment. The result will be delivered in a follow-up message.",
}
messageBus := bus.NewMessageBus()
channel, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
ch, ok := channel.(*WeComAIBotChannel)
if !ok {
t.Fatal("Expected webhook mode channel")
}
task := &streamTask{
StreamID: "stream-custom",
ChatID: "chat-custom",
Deadline: time.Now().Add(-time.Second),
}
resp := decodeStreamResponse(t, ch, ch.getStreamResponse(task, "1234567890", "nonce"))
if resp.Stream.Content != cfg.ProcessingMessage {
t.Fatalf("Expected custom processing message %q, got %q", cfg.ProcessingMessage, resp.Stream.Content)
}
})
}
@ -136,19 +222,19 @@ func TestGenerateStreamID(t *testing.T) {
Token: "test_token",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
webhookCh, ok := ch.(*WeComAIBotChannel)
if !ok {
t.Fatal("Expected webhook mode channel")
}
// Generate multiple IDs and check they are unique
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := ch.generateStreamID()
id := webhookCh.generateStreamID()
if len(id) != 10 {
t.Errorf("Expected stream ID length 10, got %d", len(id))
}
if ids[id] {
t.Errorf("Duplicate stream ID generated: %s", id)
}
@ -157,35 +243,33 @@ func TestGenerateStreamID(t *testing.T) {
}
func TestEncryptDecrypt(t *testing.T) {
// Use a valid 43-character base64 key (企业微信标准格式)
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
}
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
webhookCh, ok := ch.(*WeComAIBotChannel)
if !ok {
t.Fatal("Expected webhook mode channel")
}
plaintext := "Hello, World!"
receiveid := ""
// Encrypt
encrypted, err := ch.encryptMessage(plaintext, receiveid)
encrypted, err := webhookCh.encryptMessage(plaintext, receiveid)
if err != nil {
t.Fatalf("Failed to encrypt message: %v", err)
}
if encrypted == "" {
t.Fatal("Encrypted message is empty")
}
// Decrypt
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
if err != nil {
t.Fatalf("Failed to decrypt message: %v", err)
}
if decrypted != plaintext {
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
}
@ -198,13 +282,277 @@ func TestGenerateSignature(t *testing.T) {
encrypt := "encrypted_msg"
signature := computeSignature(token, timestamp, nonce, encrypt)
if signature == "" {
t.Error("Generated signature is empty")
}
// Verify signature using verifySignature function
if !verifySignature(token, signature, timestamp, nonce, encrypt) {
t.Error("Generated signature does not verify correctly")
}
}
func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse string) WeComAIBotStreamResponse {
t.Helper()
var wrapped WeComAIBotEncryptedResponse
if err := json.Unmarshal([]byte(encryptedResponse), &wrapped); err != nil {
t.Fatalf("Failed to unmarshal encrypted response: %v", err)
}
plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "")
if err != nil {
t.Fatalf("Failed to decrypt response: %v", err)
}
var resp WeComAIBotStreamResponse
if err := json.Unmarshal([]byte(plaintext), &resp); err != nil {
t.Fatalf("Failed to unmarshal decrypted response: %v", err)
}
return resp
}
// ---- WebSocket long-connection mode tests ----
func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
t.Run("success with bot_id and secret", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if ch == nil {
t.Fatal("Expected channel to be created")
}
if ch.Name() != "wecom_aibot" {
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
}
// WebSocket mode must NOT implement WebhookHandler.
if _, ok := ch.(channels.WebhookHandler); ok {
t.Error("WebSocket mode channel should NOT implement WebhookHandler")
}
})
t.Run("ws mode takes priority over webhook fields", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
Token: "also_set",
EncodingAESKey: "testkey1234567890123456789012345678901234567",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if _, ok := ch.(*WeComAIBotWSChannel); !ok {
t.Error("Expected WebSocket mode channel when both BotID+Secret and Token+Key are set")
}
})
t.Run("error with missing bot_id", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
Secret: "test_secret",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
// Missing bot_id alone means neither WS mode nor webhook mode is fully configured.
if err == nil {
t.Fatal("Expected error for missing bot_id, got nil")
}
})
t.Run("error with missing secret", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
}
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
t.Fatal("Expected error for missing secret, got nil")
}
})
}
func TestWeComAIBotWSChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
ctx := context.Background()
// Start launches a background goroutine; it should not block or return an error.
if err := ch.Start(ctx); err != nil {
t.Fatalf("Failed to start channel: %v", err)
}
if !ch.IsRunning() {
t.Error("Expected channel to be running after Start")
}
// Stop should work regardless of whether the WebSocket actually connected.
if err := ch.Stop(ctx); err != nil {
t.Fatalf("Failed to stop channel: %v", err)
}
if ch.IsRunning() {
t.Error("Expected channel to be stopped after Stop")
}
}
func TestGenerateRandomID(t *testing.T) {
ids := make(map[string]bool)
for i := 0; i < 200; i++ {
id := generateRandomID(10)
if len(id) != 10 {
t.Errorf("Expected ID length 10, got %d", len(id))
}
if ids[id] {
t.Errorf("Duplicate ID generated: %s", id)
}
ids[id] = true
}
}
func TestWSGenerateID(t *testing.T) {
ids := make(map[string]bool)
for i := 0; i < 200; i++ {
id := wsGenerateID()
if len(id) != 10 {
t.Errorf("Expected ID length 10, got %d", len(id))
}
if ids[id] {
t.Errorf("Duplicate wsGenerateID result: %s", id)
}
ids[id] = true
}
}
// ---- Webhook streaming fallback tests ----
// makeWebhookChannel creates a started WeComAIBotChannel for testing.
func makeWebhookChannel(t *testing.T) *WeComAIBotChannel {
t.Helper()
cfg := config.WeComAIBotConfig{
Enabled: true,
Token: "test_token",
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
}
ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("create channel: %v", err)
}
wc := ch.(*WeComAIBotChannel)
wc.ctx, wc.cancel = context.WithCancel(context.Background())
return wc
}
// makeStreamTask creates and registers a streamTask for testing.
func makeStreamTask(t *testing.T, ch *WeComAIBotChannel, streamID, chatID string, deadline time.Time) *streamTask {
t.Helper()
task := &streamTask{
StreamID: streamID,
ChatID: chatID,
Deadline: deadline,
answerCh: make(chan string, 1),
}
task.ctx, task.cancel = context.WithCancel(ch.ctx)
ch.taskMu.Lock()
ch.streamTasks[streamID] = task
ch.chatTasks[chatID] = append(ch.chatTasks[chatID], task)
ch.taskMu.Unlock()
return task
}
// TestGetStreamResponse_ImmediateAnswer verifies that when the agent has already
// placed its answer in answerCh, getStreamResponse returns a finish=true response
// and fully removes the task.
func TestGetStreamResponse_ImmediateAnswer(t *testing.T) {
ch := makeWebhookChannel(t)
defer ch.cancel()
task := makeStreamTask(t, ch, "stream-1", "chat-1", time.Now().Add(30*time.Second))
task.answerCh <- "hello from agent"
result := ch.getStreamResponse(task, "ts123", "nonce123")
if result == "" {
t.Fatal("expected non-empty encrypted response")
}
ch.taskMu.RLock()
_, exists := ch.streamTasks["stream-1"]
ch.taskMu.RUnlock()
if exists {
t.Error("task should have been removed from streamTasks after normal finish")
}
if !task.Finished {
t.Error("task.Finished should be true after normal finish")
}
}
// TestGetStreamResponse_DeadlinePassed verifies that when the stream deadline has
// elapsed (no agent reply yet), getStreamResponse closes the stream but keeps the
// task alive so the response_url fallback can still deliver the answer.
func TestGetStreamResponse_DeadlinePassed(t *testing.T) {
ch := makeWebhookChannel(t)
defer ch.cancel()
task := makeStreamTask(t, ch, "stream-2", "chat-2", time.Now().Add(-time.Millisecond))
result := ch.getStreamResponse(task, "ts456", "nonce456")
if result == "" {
t.Fatal("expected non-empty encrypted response")
}
ch.taskMu.RLock()
_, stillStreaming := ch.streamTasks["stream-2"]
ch.taskMu.RUnlock()
if stillStreaming {
t.Error("task should have been removed from streamTasks after deadline")
}
if !task.StreamClosed {
t.Error("task.StreamClosed should be true after deadline")
}
if task.Finished {
t.Error("task.Finished must remain false: agent reply still expected via response_url")
}
}
// TestGetStreamResponse_StillPending verifies that when neither the agent has
// replied nor the deadline has passed, getStreamResponse returns without altering
// task state (client should poll again).
func TestGetStreamResponse_StillPending(t *testing.T) {
ch := makeWebhookChannel(t)
defer ch.cancel()
task := makeStreamTask(t, ch, "stream-3", "chat-3", time.Now().Add(30*time.Second))
result := ch.getStreamResponse(task, "ts789", "nonce789")
if result == "" {
t.Fatal("expected non-empty encrypted response")
}
ch.taskMu.RLock()
_, exists := ch.streamTasks["stream-3"]
ch.taskMu.RUnlock()
if !exists {
t.Error("pending task should still be in streamTasks")
}
if task.Finished || task.StreamClosed {
t.Error("pending task should not be finished or stream-closed")
}
// Cleanup.
ch.removeTask(task)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,295 @@
package wecom
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
// newTestWSChannel creates a WeComAIBotWSChannel ready for unit testing.
func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel {
t.Helper()
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
}
ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("create WS channel: %v", err)
}
return ch
}
// TestStoreWSMedia_NilStore verifies that storeWSMedia returns an error when no
// MediaStore has been injected.
func TestStoreWSMedia_NilStore(t *testing.T) {
ch := newTestWSChannel(t)
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://any", "", ".jpg")
if err == nil {
t.Fatal("expected error when no MediaStore is set")
}
}
// TestStoreWSMedia_HTTPError verifies that storeWSMedia propagates HTTP errors
// from the media server.
func TestStoreWSMedia_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
ch := newTestWSChannel(t)
ch.SetMediaStore(media.NewFileMediaStore())
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
if err == nil {
t.Fatal("expected error for HTTP 404")
}
}
// TestStoreWSMedia_ServerUnavailable verifies that storeWSMedia returns a clear
// error when the media server cannot be reached.
func TestStoreWSMedia_ServerUnavailable(t *testing.T) {
ch := newTestWSChannel(t)
ch.SetMediaStore(media.NewFileMediaStore())
// Port 1 is reserved and will refuse the connection immediately.
_, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", "http://127.0.0.1:1", "", ".jpg")
if err == nil {
t.Fatal("expected error for unreachable server")
}
}
// TestStoreWSMedia_Success_NoAES verifies the happy path: the media is downloaded,
// a media ref is returned, and the file persists and is readable via Resolve until
// ReleaseAll is called. The server returns no Content-Type, so the defaultExt is used.
func TestStoreWSMedia_Success_NoAES(t *testing.T) {
imageData := bytes.Repeat([]byte("x"), 256)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(imageData)
}))
defer srv.Close()
ch := newTestWSChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
ref, err := ch.storeWSMedia(context.Background(), "chat1", "msg1", srv.URL, "", ".jpg")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if ref == "" {
t.Fatal("expected non-empty ref")
}
// File must be accessible after storeWSMedia returns (no premature deletion).
path, err := store.Resolve(ref)
if err != nil {
t.Fatalf("ref should resolve: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("file should exist at %s: %v", path, err)
}
if !bytes.Equal(got, imageData) {
t.Errorf("content mismatch: got len=%d, want len=%d", len(got), len(imageData))
}
// ReleaseAll must delete the file (store owns lifecycle).
scope := channels.BuildMediaScope("wecom_aibot", "chat1", "msg1")
if err := store.ReleaseAll(scope); err != nil {
t.Fatalf("ReleaseAll failed: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file should have been deleted by ReleaseAll, stat err: %v", err)
}
}
// TestStoreWSMedia_MultipleMessages verifies that concurrent media messages with
// different msgIDs do not collide and each resolve to distinct files.
func TestStoreWSMedia_MultipleMessages(t *testing.T) {
imageA := bytes.Repeat([]byte("a"), 64)
imageB := bytes.Repeat([]byte("b"), 64)
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(imageA)
}))
defer srvA.Close()
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(imageB)
}))
defer srvB.Close()
ch := newTestWSChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
refA, err := ch.storeWSMedia(context.Background(), "chat1", "msgA", srvA.URL, "", ".jpg")
if err != nil {
t.Fatalf("storeWSMedia A: %v", err)
}
refB, err := ch.storeWSMedia(context.Background(), "chat1", "msgB", srvB.URL, "", ".jpg")
if err != nil {
t.Fatalf("storeWSMedia B: %v", err)
}
if refA == refB {
t.Fatal("distinct messages must produce distinct refs")
}
pathA, _ := store.Resolve(refA)
pathB, _ := store.Resolve(refB)
if pathA == pathB {
t.Fatal("distinct messages must be stored at distinct paths")
}
gotA, _ := os.ReadFile(pathA)
gotB, _ := os.ReadFile(pathB)
if !bytes.Equal(gotA, imageA) {
t.Errorf("content mismatch for message A")
}
if !bytes.Equal(gotB, imageB) {
t.Errorf("content mismatch for message B")
}
}
// TestStoreWSMedia_ContentTypeExt verifies that the file extension is inferred
// from the HTTP Content-Type header and the defaultExt fallback is used when the
// type is absent or unrecognized.
func TestStoreWSMedia_ContentTypeExt(t *testing.T) {
tests := []struct {
contentType string
wantExt string
}{
{"image/jpeg", ".jpg"},
{"image/png", ".png"},
{"video/mp4", ".mp4"},
{"application/pdf", ".pdf"},
{"application/zip", ".zip"},
// With parameters stripped.
{"video/mp4; codecs=avc1", ".mp4"},
// Unknown type → falls back to defaultExt.
{"", ""},
{"application/octet-stream", ""},
}
for _, tc := range tests {
got := wsMediaExtFromContentType(tc.contentType)
if got != tc.wantExt {
t.Errorf("wsMediaExtFromContentType(%q) = %q, want %q", tc.contentType, got, tc.wantExt)
}
}
// End-to-end: server returns Content-Type: video/mp4, defaultExt is .bin.
// The stored file should carry the .mp4 extension, not .bin.
payload := bytes.Repeat([]byte("v"), 128)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
}))
defer srv.Close()
ch := newTestWSChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
ref, err := ch.storeWSMedia(context.Background(), "chat1", "vid1", srv.URL, "", ".bin")
if err != nil {
t.Fatalf("storeWSMedia: %v", err)
}
path, err := store.Resolve(ref)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if ext := path[len(path)-4:]; ext != ".mp4" {
t.Errorf("expected .mp4 extension from Content-Type, got %q", ext)
}
}
// TestSplitWSContent verifies byte-aware splitting of stream content.
func TestSplitWSContent(t *testing.T) {
t.Run("short content is not split", func(t *testing.T) {
chunks := splitWSContent("hello", 20480)
if len(chunks) != 1 || chunks[0] != "hello" {
t.Fatalf("unexpected chunks: %v", chunks)
}
})
t.Run("ASCII content split at byte boundary", func(t *testing.T) {
// Build a string just over the limit.
content := strings.Repeat("a", 20481)
chunks := splitWSContent(content, 20480)
if len(chunks) < 2 {
t.Fatalf("expected >= 2 chunks, got %d", len(chunks))
}
for i, c := range chunks {
if len(c) > 20480 {
t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
}
}
// Reassembled content must equal the original (possibly without leading
// whitespace that splitWSContent trims between chunks).
joined := strings.Join(chunks, "")
if len(joined) < len(content)-len(chunks) {
t.Errorf("joined length %d too short (original %d)", len(joined), len(content))
}
})
t.Run("CJK content split within byte limit", func(t *testing.T) {
// Each CJK rune is 3 bytes in UTF-8.
// 7000 CJK chars = 21000 bytes, which exceeds 20480.
content := strings.Repeat("\u4e2d", 7000)
chunks := splitWSContent(content, 20480)
if len(chunks) < 2 {
t.Fatalf("expected >= 2 chunks for 21000-byte CJK content, got %d", len(chunks))
}
for i, c := range chunks {
if len(c) > 20480 {
t.Errorf("chunk %d has %d bytes, want <= 20480", i, len(c))
}
// Every chunk must be valid UTF-8.
if !strings.ContainsRune(c, '\u4e2d') && len(c) > 0 {
// quick plausibility check — content was pure CJK
}
}
})
}
// TestSplitAtByteBoundary verifies the last-resort byte-boundary splitter.
func TestSplitAtByteBoundary(t *testing.T) {
t.Run("ASCII fits in one chunk", func(t *testing.T) {
parts := splitAtByteBoundary("hello world", 100)
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d", len(parts))
}
})
t.Run("splits at byte boundary, never mid-rune", func(t *testing.T) {
// 10 CJK characters = 30 bytes; split at 20 bytes.
s := strings.Repeat("\u6587", 10) // 10 × 3 bytes = 30 bytes
parts := splitAtByteBoundary(s, 20)
for i, p := range parts {
if len(p) > 20 {
t.Errorf("part %d has %d bytes, want <= 20", i, len(p))
}
// Must be valid UTF-8 (no torn multi-byte sequences).
for j, r := range p {
if r == '\uFFFD' {
t.Errorf("part %d has replacement rune at position %d: torn UTF-8", i, j)
}
}
}
})
}

View file

@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
switchCommand(),
checkCommand(),
clearCommand(),
reloadCommand(),
}
}

View file

@ -0,0 +1,20 @@
package commands
import "context"
func reloadCommand() Definition {
return Definition{
Name: "reload",
Description: "Reload the configuration file",
Usage: "/reload",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.ReloadConfig == nil {
return req.Reply(unavailableMsg)
}
if err := rt.ReloadConfig(); err != nil {
return req.Reply("Failed to reload configuration: " + err.Error())
}
return req.Reply("Config reload triggered!")
},
}
}

View file

@ -14,4 +14,5 @@ type Runtime struct {
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error
ClearHistory func() error
ReloadConfig func() error
}

View file

@ -219,26 +219,38 @@ type RoutingConfig struct {
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
// ToolFeedbackConfig controls whether tool execution details are sent to the
// chat channel as real-time feedback messages. When enabled, every tool call
// produces a short notification with the tool name and its parameters.
type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
}
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
}
const (
DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly."
)
func (d *AgentDefaults) GetMaxMediaSize() int {
if d.MaxMediaSize > 0 {
@ -247,6 +259,19 @@ func (d *AgentDefaults) GetMaxMediaSize() int {
return DefaultMaxMediaSize
}
// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages.
func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
if d.ToolFeedback.MaxArgsLength > 0 {
return d.ToolFeedback.MaxArgsLength
}
return 300
}
// IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat.
func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
return d.ToolFeedback.Enabled
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
@ -272,6 +297,7 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"`
}
@ -332,6 +358,7 @@ type FeishuConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
}
type DiscordConfig struct {
@ -355,14 +382,15 @@ type MaixCamConfig struct {
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"`
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
type DingTalkConfig struct {
@ -457,15 +485,18 @@ type WeComAppConfig struct {
}
type WeComAIBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
Secret string `json:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
Token string `json:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
@ -481,6 +512,16 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type PicoClientConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"`
URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
SessionID string `json:"session_id,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"`
}
type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
@ -609,9 +650,11 @@ type ModelConfig struct {
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // API authentication key
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // API authentication key (single key)
APIKeys []string `json:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover
// Special providers (CLI-based, OAuth, etc.)
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
@ -809,6 +852,10 @@ type ClawHubRegistryConfig struct {
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
Enabled bool `json:"enabled"`
// Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode).
// When nil, the global Discovery.Enabled setting applies.
// When explicitly set to true or false, it overrides the global setting for this server only.
Deferred *bool `json:"deferred,omitempty"`
// Command is the executable to run (e.g., "npx", "python", "/path/to/server")
Command string `json:"command"`
// Args are the arguments to pass to the command
@ -880,6 +927,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
@ -888,6 +938,15 @@ func LoadConfig(path string) (*Config, error) {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
// Inherit credentials from providers to model_list entries (#1635).
// When both providers and model_list are present, model_list entries
// whose api_key/api_base are empty will inherit from the matching
// provider (matched by protocol prefix). Explicit model_list values
// always take precedence.
if cfg.HasProvidersConfig() {
InheritProviderCredentials(cfg.ModelList, cfg.Providers)
}
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
return nil, err
@ -926,14 +985,25 @@ func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelCo
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
// Also resolves api_keys array if present.
func resolveAPIKeys(models []ModelConfig, configDir string) error {
cr := credential.NewResolver(configDir)
for i := range models {
// Resolve single APIKey
resolved, err := cr.Resolve(models[i].APIKey)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
}
models[i].APIKey = resolved
// Resolve APIKeys array
for j, key := range models[i].APIKeys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf("model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err)
}
models[i].APIKeys[j] = resolved
}
}
return nil
}
@ -1104,6 +1174,89 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
return all
}
// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.
//
// Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]}
// Becomes:
// - {"model_name": "gpt-4", "api_key": "k1", "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
// - {"model_name": "gpt-4__key_1", "api_key": "k2"}
// - {"model_name": "gpt-4__key_2", "api_key": "k3"}
func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
var expanded []ModelConfig
for _, m := range models {
keys := MergeAPIKeys(m.APIKey, m.APIKeys)
// Single key or no keys: keep as-is
if len(keys) <= 1 {
// Ensure APIKey is set from APIKeys if needed
if m.APIKey == "" && len(keys) == 1 {
m.APIKey = keys[0]
}
m.APIKeys = nil // Clear APIKeys to avoid confusion
expanded = append(expanded, m)
continue
}
// Multiple keys: expand
originalName := m.ModelName
// Create entries for additional keys (key_1, key_2, ...)
var fallbackNames []string
for i := 1; i < len(keys); i++ {
suffix := fmt.Sprintf("__key_%d", i)
expandedName := originalName + suffix
// Create a copy for the additional key
additionalEntry := ModelConfig{
ModelName: expandedName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[i],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
}
// Create the primary entry with first key and fallbacks
primaryEntry := ModelConfig{
ModelName: originalName,
Model: m.Model,
APIBase: m.APIBase,
APIKey: keys[0],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
Workspace: m.Workspace,
RPM: m.RPM,
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
}
// Prepend new fallbacks to existing ones
if len(fallbackNames) > 0 {
primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...)
} else if len(m.Fallbacks) > 0 {
primaryEntry.Fallbacks = m.Fallbacks
}
expanded = append(expanded, primaryEntry)
}
return expanded
}
func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":

View file

@ -35,6 +35,10 @@ func DefaultConfig() *Config {
MaxToolIterations: 50,
SummarizeMessageThreshold: 20,
SummarizeTokenPercent: 75,
ToolFeedback: ToolFeedbackConfig{
Enabled: true,
MaxArgsLength: 300,
},
},
},
Bindings: []AgentBinding{},
@ -82,11 +86,12 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
MaxBase64FileSizeMiB: 0,
},
DingTalk: DingTalkConfig{
Enabled: false,
@ -159,14 +164,15 @@ func DefaultConfig() *Config {
ReplyTimeout: 5,
},
WeComAIBot: WeComAIBotConfig{
Enabled: false,
Token: "",
EncodingAESKey: "",
WebhookPath: "/webhook/wecom-aibot",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
MaxSteps: 10,
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
Enabled: false,
Token: "",
EncodingAESKey: "",
WebhookPath: "/webhook/wecom-aibot",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
MaxSteps: 10,
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
ProcessingMessage: DefaultWeComAIBotProcessingMessage,
},
Pico: PicoConfig{
Enabled: false,

View file

@ -468,3 +468,84 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return result
}
// protocolProviderMapping maps a model protocol prefix (the part before "/" in
// the Model field) to a function that extracts the corresponding ProviderConfig
// from the legacy ProvidersConfig. Used by InheritProviderCredentials.
var protocolProviderMapping = map[string]func(p ProvidersConfig) ProviderConfig{
"openai": func(p ProvidersConfig) ProviderConfig { return p.OpenAI.ProviderConfig },
"anthropic": func(p ProvidersConfig) ProviderConfig { return p.Anthropic },
"litellm": func(p ProvidersConfig) ProviderConfig { return p.LiteLLM },
"openrouter": func(p ProvidersConfig) ProviderConfig { return p.OpenRouter },
"groq": func(p ProvidersConfig) ProviderConfig { return p.Groq },
"zhipu": func(p ProvidersConfig) ProviderConfig { return p.Zhipu },
"vllm": func(p ProvidersConfig) ProviderConfig { return p.VLLM },
"gemini": func(p ProvidersConfig) ProviderConfig { return p.Gemini },
"nvidia": func(p ProvidersConfig) ProviderConfig { return p.Nvidia },
"ollama": func(p ProvidersConfig) ProviderConfig { return p.Ollama },
"moonshot": func(p ProvidersConfig) ProviderConfig { return p.Moonshot },
"shengsuanyun": func(p ProvidersConfig) ProviderConfig { return p.ShengSuanYun },
"deepseek": func(p ProvidersConfig) ProviderConfig { return p.DeepSeek },
"cerebras": func(p ProvidersConfig) ProviderConfig { return p.Cerebras },
"vivgrid": func(p ProvidersConfig) ProviderConfig { return p.Vivgrid },
"volcengine": func(p ProvidersConfig) ProviderConfig { return p.VolcEngine },
"github-copilot": func(p ProvidersConfig) ProviderConfig { return p.GitHubCopilot },
"antigravity": func(p ProvidersConfig) ProviderConfig { return p.Antigravity },
"qwen": func(p ProvidersConfig) ProviderConfig { return p.Qwen },
"mistral": func(p ProvidersConfig) ProviderConfig { return p.Mistral },
"avian": func(p ProvidersConfig) ProviderConfig { return p.Avian },
"minimax": func(p ProvidersConfig) ProviderConfig { return p.Minimax },
"longcat": func(p ProvidersConfig) ProviderConfig { return p.LongCat },
"modelscope": func(p ProvidersConfig) ProviderConfig { return p.ModelScope },
"novita": func(p ProvidersConfig) ProviderConfig { return p.Novita },
}
// InheritProviderCredentials fills in missing api_key, api_base, proxy, and
// request_timeout on model_list entries from the matching legacy providers
// configuration. The match is determined by the protocol prefix in the Model
// field (e.g. "deepseek/deepseek-chat" matches providers.deepseek).
//
// Only empty fields are filled — any value explicitly set on a model_list entry
// takes precedence. This function modifies the slice in place.
//
// This bridges the gap described in issue #1635: users who configure
// credentials once in the providers section expect model_list entries using
// the same protocol to "just work" without duplicating credentials.
func InheritProviderCredentials(models []ModelConfig, providers ProvidersConfig) {
if providers.IsEmpty() {
return
}
for i := range models {
m := &models[i]
// Extract protocol prefix from Model field
protocol := ""
if idx := strings.Index(m.Model, "/"); idx > 0 {
protocol = strings.ToLower(m.Model[:idx])
}
if protocol == "" {
continue
}
getProvider, ok := protocolProviderMapping[protocol]
if !ok {
continue
}
pc := getProvider(providers)
// Only fill empty fields — explicit model_list values win
if m.APIKey == "" && pc.APIKey != "" {
m.APIKey = pc.APIKey
}
if m.APIBase == "" && pc.APIBase != "" {
m.APIBase = pc.APIBase
}
if m.Proxy == "" && pc.Proxy != "" {
m.Proxy = pc.Proxy
}
if m.RequestTimeout == 0 && pc.RequestTimeout != 0 {
m.RequestTimeout = pc.RequestTimeout
}
}
}

View file

@ -613,3 +613,143 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T)
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
}
}
// ---------- InheritProviderCredentials tests ----------
func TestInheritProviderCredentials_FillsMissingAPIKey(t *testing.T) {
models := []ModelConfig{
{ModelName: "my-deepseek", Model: "deepseek/deepseek-chat"},
}
providers := ProvidersConfig{
DeepSeek: ProviderConfig{
APIKey: "sk-deepseek-from-providers",
APIBase: "https://api.deepseek.com/v1",
},
}
InheritProviderCredentials(models, providers)
if models[0].APIKey != "sk-deepseek-from-providers" {
t.Errorf("APIKey = %q, want %q", models[0].APIKey, "sk-deepseek-from-providers")
}
if models[0].APIBase != "https://api.deepseek.com/v1" {
t.Errorf("APIBase = %q, want %q", models[0].APIBase, "https://api.deepseek.com/v1")
}
}
func TestInheritProviderCredentials_ExplicitValuesTakePrecedence(t *testing.T) {
models := []ModelConfig{
{
ModelName: "my-openai",
Model: "openai/gpt-5.4",
APIKey: "sk-explicit-model-key",
APIBase: "https://my-custom-endpoint.com/v1",
},
}
providers := ProvidersConfig{
OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{
APIKey: "sk-provider-key",
APIBase: "https://api.openai.com/v1",
},
},
}
InheritProviderCredentials(models, providers)
if models[0].APIKey != "sk-explicit-model-key" {
t.Errorf("APIKey = %q, want %q (explicit should win)", models[0].APIKey, "sk-explicit-model-key")
}
if models[0].APIBase != "https://my-custom-endpoint.com/v1" {
t.Errorf("APIBase = %q, want %q (explicit should win)", models[0].APIBase, "https://my-custom-endpoint.com/v1")
}
}
func TestInheritProviderCredentials_MultipleModels(t *testing.T) {
models := []ModelConfig{
{ModelName: "groq-llama", Model: "groq/llama-3.1-70b"},
{ModelName: "zhipu-glm", Model: "zhipu/glm-4"},
{ModelName: "custom-openai", Model: "openai/gpt-5.4", APIKey: "sk-already-set"},
}
providers := ProvidersConfig{
Groq: ProviderConfig{APIKey: "gsk-groq-key", Proxy: "http://proxy:8080"},
Zhipu: ProviderConfig{APIKey: "zhipu-key-123", APIBase: "https://zhipu.example.com"},
OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{APIKey: "sk-should-not-override"},
},
}
InheritProviderCredentials(models, providers)
// groq model should inherit
if models[0].APIKey != "gsk-groq-key" {
t.Errorf("groq APIKey = %q, want %q", models[0].APIKey, "gsk-groq-key")
}
if models[0].Proxy != "http://proxy:8080" {
t.Errorf("groq Proxy = %q, want %q", models[0].Proxy, "http://proxy:8080")
}
// zhipu model should inherit
if models[1].APIKey != "zhipu-key-123" {
t.Errorf("zhipu APIKey = %q, want %q", models[1].APIKey, "zhipu-key-123")
}
if models[1].APIBase != "https://zhipu.example.com" {
t.Errorf("zhipu APIBase = %q, want %q", models[1].APIBase, "https://zhipu.example.com")
}
// openai model already has key — should NOT be overridden
if models[2].APIKey != "sk-already-set" {
t.Errorf("openai APIKey = %q, want %q (should not be overridden)", models[2].APIKey, "sk-already-set")
}
}
func TestInheritProviderCredentials_NoMatchingProvider(t *testing.T) {
models := []ModelConfig{
{ModelName: "my-model", Model: "novelai/some-model"},
}
providers := ProvidersConfig{
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
}
InheritProviderCredentials(models, providers)
// No matching provider for "novelai" protocol — should stay empty
if models[0].APIKey != "" {
t.Errorf("APIKey = %q, want empty (no matching provider)", models[0].APIKey)
}
}
func TestInheritProviderCredentials_EmptyProviders(t *testing.T) {
models := []ModelConfig{
{ModelName: "my-model", Model: "openai/gpt-5.4"},
}
providers := ProvidersConfig{} // all empty
InheritProviderCredentials(models, providers)
// Empty providers — nothing to inherit
if models[0].APIKey != "" {
t.Errorf("APIKey = %q, want empty", models[0].APIKey)
}
}
func TestInheritProviderCredentials_InheritsRequestTimeout(t *testing.T) {
models := []ModelConfig{
{ModelName: "my-ollama", Model: "ollama/llama3.2:3b"},
}
providers := ProvidersConfig{
Ollama: ProviderConfig{
APIBase: "http://localhost:11434",
RequestTimeout: 120,
},
}
InheritProviderCredentials(models, providers)
if models[0].APIBase != "http://localhost:11434" {
t.Errorf("APIBase = %q, want %q", models[0].APIBase, "http://localhost:11434")
}
if models[0].RequestTimeout != 120 {
t.Errorf("RequestTimeout = %d, want 120", models[0].RequestTimeout)
}
}

291
pkg/config/multikey_test.go Normal file
View file

@ -0,0 +1,291 @@
package config
import (
"testing"
)
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "single-key",
},
}
result := ExpandMultiKeyModels(models)
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
}
if result[0].ModelName != "gpt-4" {
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
}
if result[0].APIKey != "single-key" {
t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey)
}
if len(result[0].Fallbacks) != 0 {
t.Errorf("expected no fallbacks, got %v", result[0].Fallbacks)
}
}
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
models := []ModelConfig{
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://api.example.com",
APIKeys: []string{"key1", "key2", "key3"},
},
}
result := ExpandMultiKeyModels(models)
// Should expand to 3 models
if len(result) != 3 {
t.Fatalf("expected 3 models, got %d", len(result))
}
// First entry should be the primary with key1 and fallbacks
primary := result[2] // Primary is added last
if primary.ModelName != "glm-4.7" {
t.Errorf("expected primary model_name 'glm-4.7', got %q", primary.ModelName)
}
if primary.APIKey != "key1" {
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
}
if primary.Fallbacks[0] != "glm-4.7__key_1" {
t.Errorf("expected first fallback 'glm-4.7__key_1', got %q", primary.Fallbacks[0])
}
if primary.Fallbacks[1] != "glm-4.7__key_2" {
t.Errorf("expected second fallback 'glm-4.7__key_2', got %q", primary.Fallbacks[1])
}
// Second entry should be key2
second := result[0]
if second.ModelName != "glm-4.7__key_1" {
t.Errorf("expected second model_name 'glm-4.7__key_1', got %q", second.ModelName)
}
if second.APIKey != "key2" {
t.Errorf("expected second api_key 'key2', got %q", second.APIKey)
}
// Third entry should be key3
third := result[1]
if third.ModelName != "glm-4.7__key_2" {
t.Errorf("expected third model_name 'glm-4.7__key_2', got %q", third.ModelName)
}
if third.APIKey != "key3" {
t.Errorf("expected third api_key 'key3', got %q", third.APIKey)
}
}
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "key0",
APIKeys: []string{"key1", "key2"},
},
}
result := ExpandMultiKeyModels(models)
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
if len(result) != 3 {
t.Fatalf("expected 3 models, got %d", len(result))
}
// Primary should use key0
primary := result[2]
if primary.APIKey != "key0" {
t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks, got %d", len(primary.Fallbacks))
}
}
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKeys: []string{"key1", "key2"},
Fallbacks: []string{"claude-3"},
},
}
result := ExpandMultiKeyModels(models)
primary := result[1]
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
if len(primary.Fallbacks) != 2 {
t.Fatalf("expected 2 fallbacks, got %d: %v", len(primary.Fallbacks), primary.Fallbacks)
}
// Key fallbacks should come first, then existing fallbacks
if primary.Fallbacks[0] != "gpt-4__key_1" {
t.Errorf("expected first fallback 'gpt-4__key_1', got %q", primary.Fallbacks[0])
}
if primary.Fallbacks[1] != "claude-3" {
t.Errorf("expected second fallback 'claude-3', got %q", primary.Fallbacks[1])
}
}
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "",
APIKeys: []string{},
},
}
result := ExpandMultiKeyModels(models)
// Should keep as-is with no changes
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
}
if result[0].ModelName != "gpt-4" {
t.Errorf("expected model_name 'gpt-4', got %q", result[0].ModelName)
}
}
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIKey: "key1",
APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
},
}
result := ExpandMultiKeyModels(models)
// Should only create 2 models (deduplicated keys)
if len(result) != 2 {
t.Fatalf("expected 2 models (deduplicated), got %d", len(result))
}
primary := result[1]
if primary.APIKey != "key1" {
t.Errorf("expected primary api_key 'key1', got %q", primary.APIKey)
}
if len(primary.Fallbacks) != 1 {
t.Errorf("expected 1 fallback, got %d", len(primary.Fallbacks))
}
}
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
models := []ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
APIBase: "https://api.example.com",
APIKeys: []string{"key1", "key2"},
Proxy: "http://proxy:8080",
RPM: 60,
MaxTokensField: "max_completion_tokens",
RequestTimeout: 30,
ThinkingLevel: "high",
},
}
result := ExpandMultiKeyModels(models)
// Check primary entry preserves all fields
primary := result[1]
if primary.APIBase != "https://api.example.com" {
t.Errorf("expected api_base preserved, got %q", primary.APIBase)
}
if primary.Proxy != "http://proxy:8080" {
t.Errorf("expected proxy preserved, got %q", primary.Proxy)
}
if primary.RPM != 60 {
t.Errorf("expected rpm preserved, got %d", primary.RPM)
}
if primary.MaxTokensField != "max_completion_tokens" {
t.Errorf("expected max_tokens_field preserved, got %q", primary.MaxTokensField)
}
if primary.RequestTimeout != 30 {
t.Errorf("expected request_timeout preserved, got %d", primary.RequestTimeout)
}
if primary.ThinkingLevel != "high" {
t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel)
}
// Check additional entry also preserves fields
additional := result[0]
if additional.APIBase != "https://api.example.com" {
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
}
if additional.RPM != 60 {
t.Errorf("expected additional rpm preserved, got %d", additional.RPM)
}
}
func TestMergeAPIKeys(t *testing.T) {
tests := []struct {
name string
apiKey string
apiKeys []string
expected []string
}{
{
name: "both empty",
apiKey: "",
apiKeys: nil,
expected: nil,
},
{
name: "only apiKey",
apiKey: "key1",
apiKeys: nil,
expected: []string{"key1"},
},
{
name: "only apiKeys",
apiKey: "",
apiKeys: []string{"key1", "key2"},
expected: []string{"key1", "key2"},
},
{
name: "both with overlap",
apiKey: "key1",
apiKeys: []string{"key1", "key2", "key3"},
expected: []string{"key1", "key2", "key3"},
},
{
name: "with whitespace",
apiKey: " key1 ",
apiKeys: []string{" key2 ", " key1 "},
expected: []string{"key1", "key2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
if len(result) != len(tt.expected) {
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
}
for i, k := range result {
if k != tt.expected[i] {
t.Errorf("expected key[%d] = %q, got %q", i, tt.expected[i], k)
}
}
})
}
}

View file

@ -7,6 +7,7 @@ import (
"os/signal"
"path/filepath"
"sync"
"sync/atomic"
"syscall"
"time"
@ -54,6 +55,8 @@ type services struct {
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
manualReloadChan chan struct{}
reloading atomic.Bool
}
type startupBlockedProvider struct {
@ -117,6 +120,25 @@ func Run(debug bool, configPath string, allowEmptyStartup bool) error {
return err
}
// Setup manual reload channel for /reload endpoint
manualReloadChan := make(chan struct{}, 1)
runningServices.manualReloadChan = manualReloadChan
reloadTrigger := func() error {
if !runningServices.reloading.CompareAndSwap(false, true) {
return fmt.Errorf("reload already in progress")
}
select {
case manualReloadChan <- struct{}{}:
return nil
default:
// Should not happen, but reset flag if channel is full
runningServices.reloading.Store(false)
return fmt.Errorf("reload already queued")
}
}
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
agentLoop.SetReloadFunc(reloadTrigger)
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
@ -143,14 +165,50 @@ func Run(debug bool, configPath string, allowEmptyStartup bool) error {
shutdownGateway(runningServices, agentLoop, provider, true)
return nil
case newCfg := <-configReloadChan:
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if !runningServices.reloading.CompareAndSwap(false, true) {
logger.Warn("Config reload skipped: another reload is in progress")
continue
}
err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil {
logger.Errorf("Config reload failed: %v", err)
}
case <-manualReloadChan:
logger.Info("Manual reload triggered via /reload endpoint")
newCfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Errorf("Error loading config for manual reload: %v", err)
runningServices.reloading.Store(false)
continue
}
if err = newCfg.ValidateModelList(); err != nil {
logger.Errorf("Config validation failed: %v", err)
runningServices.reloading.Store(false)
continue
}
err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil {
logger.Errorf("Manual reload failed: %v", err)
} else {
logger.Info("Manual reload completed successfully")
}
}
}
}
func executeReload(
ctx context.Context,
agentLoop *agent.AgentLoop,
newCfg *config.Config,
provider *providers.LLMProvider,
runningServices *services,
msgBus *bus.MessageBus,
allowEmptyStartup bool,
) error {
defer runningServices.reloading.Store(false)
return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup)
}
func createStartupProvider(
cfg *config.Config,
allowEmptyStartup bool,
@ -245,7 +303,11 @@ func setupAndStartServices(
return nil, fmt.Errorf("error starting channels: %w", err)
}
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{
@ -262,11 +324,12 @@ func setupAndStartServices(
return runningServices, nil
}
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) {
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration, isReload bool) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
if runningServices.ChannelManager != nil {
// reload should not stop channel manager
if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx)
}
if runningServices.DeviceService != nil {
@ -295,7 +358,7 @@ func shutdownGateway(
cp.Close()
}
stopAndCleanupServices(runningServices, gracefulShutdownTimeout)
stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false)
agentLoop.Stop()
agentLoop.Close()
@ -322,7 +385,7 @@ func handleConfigReload(
logger.Infof(" New model is '%s', recreating provider...", newModel)
logger.Info(" Stopping all services...")
stopAndCleanupServices(runningServices, serviceShutdownTimeout)
stopAndCleanupServices(runningServices, serviceShutdownTimeout, true)
newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
if err != nil {
@ -426,17 +489,16 @@ func restartServices(
}
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
// Reuse existing HealthServer to preserve reloadFunc
if runningServices.HealthServer == nil {
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
}
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return fmt.Errorf("error restarting channels: %w", err)
if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {
return fmt.Errorf("error reload channels: %w", err)
}
fmt.Printf(
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
fmt.Println(" ✓ Channels restarted.")
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{

View file

@ -12,11 +12,12 @@ import (
)
type Server struct {
server *http.Server
mu sync.RWMutex
ready bool
checks map[string]Check
startTime time.Time
server *http.Server
mu sync.RWMutex
ready bool
checks map[string]Check
startTime time.Time
reloadFunc func() error
}
type Check struct {
@ -43,6 +44,7 @@ func NewServer(host string, port int) *Server {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler)
addr := fmt.Sprintf("%s:%d", host, port)
s.server = &http.Server{
@ -106,6 +108,44 @@ func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
}
}
// SetReloadFunc sets the callback function for config reload.
func (s *Server) SetReloadFunc(fn func() error) {
s.mu.Lock()
defer s.mu.Unlock()
s.reloadFunc = fn
}
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"})
return
}
s.mu.Lock()
reloadFunc := s.reloadFunc
s.mu.Unlock()
if reloadFunc == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "reload not configured"})
return
}
if err := reloadFunc(); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@ -158,11 +198,12 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
})
}
// RegisterOnMux registers /health and /ready handlers onto the given mux.
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
func (s *Server) RegisterOnMux(mux *http.ServeMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler)
}
func statusString(ok bool) string {

View file

@ -221,6 +221,10 @@ func buildRequestBody(
// Add tool_use blocks
for _, tc := range msg.ToolCalls {
if strings.TrimSpace(tc.Name) == "" {
continue
}
// Handle nil Arguments (GLM-4 may return null input)
input := tc.Arguments
if input == nil {

View file

@ -492,6 +492,20 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) {
},
wantErr: false,
},
{
name: "skip tool calls with empty names",
messages: []Message{
{Role: "assistant", Content: "Calling tool", ToolCalls: []ToolCall{
{ID: "tool-empty", Name: "", Arguments: map[string]any{"ignored": true}},
{ID: "tool-valid", Name: "test_tool", Arguments: map[string]any{"arg": "value"}},
}},
},
model: "test-model",
options: map[string]any{
"max_tokens": 8192,
},
wantErr: false,
},
}
for _, tt := range tests {
@ -513,6 +527,37 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) {
if got["model"] != tt.model {
t.Errorf("model = %v, want %v", got["model"], tt.model)
}
if tt.name == "skip tool calls with empty names" {
messages, ok := got["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v, want single assistant message", got["messages"])
}
assistantMsg, ok := messages[0].(map[string]any)
if !ok {
t.Fatalf("assistant message = %#v, want map", messages[0])
}
content, ok := assistantMsg["content"].([]any)
if !ok {
t.Fatalf("assistant content = %#v, want []any", assistantMsg["content"])
}
if len(content) != 2 {
t.Fatalf("assistant content length = %d, want 2", len(content))
}
toolUse, ok := content[1].(map[string]any)
if !ok {
t.Fatalf("tool_use block = %#v, want map", content[1])
}
if gotName := toolUse["name"]; gotName != "test_tool" {
t.Fatalf("tool_use name = %v, want %q", gotName, "test_tool")
}
if gotID := toolUse["id"]; gotID != "tool-valid" {
t.Fatalf("tool_use id = %v, want %q", gotID, "tool-valid")
}
}
})
}
}

View file

@ -115,8 +115,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax", "longcat", "modelscope", "novita":
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -173,6 +174,21 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout,
), modelID, nil
case "coding-plan-anthropic", "alibaba-coding-anthropic":
// Alibaba Coding Plan with Anthropic-compatible API
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model)
}
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey,
apiBase,
cfg.RequestTimeout,
), modelID, nil
case "antigravity":
return NewAntigravityProvider(), modelID, nil
@ -245,6 +261,14 @@ func getDefaultAPIBase(protocol string) string {
return "https://ark.cn-beijing.volces.com/api/v3"
case "qwen":
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "qwen-intl", "qwen-international", "dashscope-intl":
return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
case "qwen-us", "dashscope-us":
return "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
case "coding-plan", "alibaba-coding", "qwen-coding":
return "https://coding-intl.dashscope.aliyuncs.com/v1"
case "coding-plan-anthropic", "alibaba-coding-anthropic":
return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
case "vllm":
return "http://localhost:8000/v1"
case "mistral":

View file

@ -472,3 +472,134 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
t.Fatal("CreateProviderFromConfig() expected error for missing API base")
}
}
func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
tests := []struct {
name string
protocol string
}{
{"qwen-international", "qwen-international"},
{"dashscope-intl", "dashscope-intl"},
{"qwen-intl", "qwen-intl"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/qwen-max",
APIKey: "test-key",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "qwen-max" {
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
})
}
}
func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
tests := []struct {
name string
protocol string
}{
{"qwen-us", "qwen-us"},
{"dashscope-us", "dashscope-us"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/qwen-max",
APIKey: "test-key",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "qwen-max" {
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
})
}
}
func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
tests := []struct {
name string
protocol string
}{
{"coding-plan-anthropic", "coding-plan-anthropic"},
{"alibaba-coding-anthropic", "alibaba-coding-anthropic"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/claude-sonnet-4-20250514",
APIKey: "test-key",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "claude-sonnet-4-20250514" {
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514")
}
// coding-plan-anthropic uses Anthropic Messages provider
// Verify it's the anthropic messages provider by checking interface
var _ LLMProvider = provider
})
}
}
func TestGetDefaultAPIBase_CodingPlanAnthropic(t *testing.T) {
expectedURL := "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
if got := getDefaultAPIBase("coding-plan-anthropic"); got != expectedURL {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "coding-plan-anthropic", got, expectedURL)
}
if got := getDefaultAPIBase("alibaba-coding-anthropic"); got != expectedURL {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "alibaba-coding-anthropic", got, expectedURL)
}
}
func TestGetDefaultAPIBase_QwenIntlAliases(t *testing.T) {
expectedURL := "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
for _, protocol := range []string{"qwen-intl", "qwen-international", "dashscope-intl"} {
if got := getDefaultAPIBase(protocol); got != expectedURL {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL)
}
}
}
func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) {
expectedURL := "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
for _, protocol := range []string{"qwen-us", "dashscope-us"} {
if got := getDefaultAPIBase(protocol); got != expectedURL {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", protocol, got, expectedURL)
}
}
}

View file

@ -117,17 +117,19 @@ func (fc *FallbackChain) Execute(
return nil, context.Canceled
}
// Check cooldown.
if !fc.cooldown.IsAvailable(candidate.Provider) {
remaining := fc.cooldown.CooldownRemaining(candidate.Provider)
// Check cooldown (per provider/model, not just provider).
// This allows multi-key failover where different keys use different model names.
cooldownKey := ModelKey(candidate.Provider, candidate.Model)
if !fc.cooldown.IsAvailable(cooldownKey) {
remaining := fc.cooldown.CooldownRemaining(cooldownKey)
result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider,
Model: candidate.Model,
Skipped: true,
Reason: FailoverRateLimit,
Error: fmt.Errorf(
"provider %s in cooldown (%s remaining)",
candidate.Provider,
"%s in cooldown (%s remaining)",
cooldownKey,
remaining.Round(time.Second),
),
})
@ -141,7 +143,7 @@ func (fc *FallbackChain) Execute(
if err == nil {
// Success.
fc.cooldown.MarkSuccess(candidate.Provider)
fc.cooldown.MarkSuccess(cooldownKey)
result.Response = resp
result.Provider = candidate.Provider
result.Model = candidate.Model
@ -187,7 +189,7 @@ func (fc *FallbackChain) Execute(
}
// Retriable error: mark failure and continue to next candidate.
fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason)
fc.cooldown.MarkFailure(cooldownKey, failErr.Reason)
result.Attempts = append(result.Attempts, FallbackAttempt{
Provider: candidate.Provider,
Model: candidate.Model,

View file

@ -0,0 +1,384 @@
package providers
import (
"context"
"errors"
"testing"
)
// TestMultiKeyFailover tests the complete failover flow with multiple API keys.
// This simulates the config expansion scenario where api_keys: ["key1", "key2", "key3"]
// is expanded into primary + fallbacks.
func TestMultiKeyFailover(t *testing.T) {
// Simulate expanded config: primary with 2 fallbacks
// This is what ExpandMultiKeyModels would produce for api_keys: ["key1", "key2", "key3"]
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates)
}
// Create fallback chain
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first call fails with 429, second succeeds
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
if callCount == 1 {
// First call: simulate rate limit
return nil, errors.New("http error: status 429 - rate limit exceeded")
}
// Second call: success
return &LLMResponse{
Content: "Hello from key2!",
}, nil
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after failover, got error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Response.Content != "Hello from key2!" {
t.Errorf("expected response from key2, got: %s", result.Response.Content)
}
if callCount != 2 {
t.Errorf("expected 2 calls (1 fail + 1 success), got %d", callCount)
}
// Verify first attempt was recorded
if len(result.Attempts) != 1 {
t.Errorf("expected 1 failed attempt recorded, got %d", len(result.Attempts))
}
if result.Attempts[0].Reason != FailoverRateLimit {
t.Errorf(
"expected first attempt reason to be rate_limit, got: %s",
result.Attempts[0].Reason,
)
}
}
// TestMultiKeyFailoverAllFail tests when all keys hit rate limit
func TestMultiKeyFailoverAllFail(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: all calls fail with rate limit
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
return nil, errors.New("status: 429 - too many requests")
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err == nil {
t.Fatal("expected error when all keys fail, got nil")
}
if result != nil {
t.Errorf("expected nil result on failure, got: %v", result)
}
if callCount != 3 {
t.Errorf("expected 3 calls (all fail), got %d", callCount)
}
// Verify error type
var exhausted *FallbackExhaustedError
if !errors.As(err, &exhausted) {
t.Errorf("expected FallbackExhaustedError, got: %T - %v", err, err)
}
if len(exhausted.Attempts) != 3 {
t.Errorf("expected 3 attempts in exhausted error, got %d", len(exhausted.Attempts))
}
}
// TestMultiKeyFailoverCooldown tests that a key in cooldown is skipped
func TestMultiKeyFailoverCooldown(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Put the first model in cooldown (using ModelKey now, not just provider)
cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model)
cooldown.MarkFailure(cooldownKey, FailoverRateLimit)
// Verify it's not available
if cooldown.IsAvailable(cooldownKey) {
t.Fatal("expected first model to be in cooldown")
}
// Mock run function: only second should be called
callCount := 0
calledProviders := []string{}
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
calledProviders = append(calledProviders, provider+"/"+model)
return &LLMResponse{Content: "success"}, nil
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// First provider should have been skipped
if callCount != 1 {
t.Errorf("expected 1 call (first skipped due to cooldown), got %d", callCount)
}
// Should have called the second provider/model
if len(calledProviders) != 1 ||
calledProviders[0] != candidates[1].Provider+"/"+candidates[1].Model {
t.Errorf("expected second model to be called, got: %v", calledProviders)
}
// Verify first attempt was recorded as skipped
if len(result.Attempts) != 1 {
t.Fatalf("expected 1 attempt (skipped), got %d", len(result.Attempts))
}
if !result.Attempts[0].Skipped {
t.Error("expected first attempt to be marked as skipped")
}
}
// TestMultiKeyFailoverWithFormatError tests that format errors are non-retriable
func TestMultiKeyFailoverWithFormatError(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first call fails with format error (bad request)
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
return nil, errors.New("invalid request format: tool_use.id missing")
}
// Execute fallback chain
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err == nil {
t.Fatal("expected error for format failure, got nil")
}
// Format errors should NOT trigger failover (non-retriable)
// So we should only have 1 call
if callCount != 1 {
t.Errorf("expected 1 call (format error is non-retriable), got %d", callCount)
}
// Verify the error is a FailoverError with format reason
var failoverErr *FailoverError
if !errors.As(err, &failoverErr) {
t.Errorf("expected FailoverError, got: %T - %v", err, err)
}
if failoverErr.Reason != FailoverFormat {
t.Errorf("expected FailoverFormat reason, got: %s", failoverErr.Reason)
}
_ = result // result should be nil
}
// TestMultiKeyWithModelFallback tests multi-key failover combined with model fallback.
// This simulates the scenario: api_keys: ["k1", "k2"] + fallbacks: ["minimax"]
// Expected failover order: glm-4.7 (k1) → glm-4.7__key_1 (k2) → minimax
func TestMultiKeyWithModelFallback(t *testing.T) {
// Simulate expanded config from:
// { "model_name": "glm-4.7", "api_keys": ["k1", "k2"], "fallbacks": ["minimax"] }
// After ExpandMultiKeyModels, primaryEntry.Fallbacks = ["glm-4.7__key_1", "minimax"]
// Note: In production, "minimax" would be resolved via model lookup to "minimax/minimax"
// In this test, we use the full format to avoid needing a lookup function.
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "minimax/minimax"},
}
candidates := ResolveCandidates(cfg, "zhipu")
// Should have 3 candidates: glm-4.7 (zhipu), glm-4.7__key_1 (zhipu), minimax (minimax)
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates, got %d: %v", len(candidates), candidates)
}
// Verify candidate order
if candidates[0].Model != "glm-4.7" || candidates[0].Provider != "zhipu" {
t.Errorf(
"expected first candidate to be zhipu/glm-4.7, got: %s/%s",
candidates[0].Provider,
candidates[0].Model,
)
}
if candidates[1].Model != "glm-4.7__key_1" || candidates[1].Provider != "zhipu" {
t.Errorf(
"expected second candidate to be zhipu/glm-4.7__key_1, got: %s/%s",
candidates[1].Provider,
candidates[1].Model,
)
}
if candidates[2].Model != "minimax" || candidates[2].Provider != "minimax" {
t.Errorf(
"expected third candidate to be minimax/minimax, got: %s/%s",
candidates[2].Provider,
candidates[2].Model,
)
}
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: first two fail, third succeeds (model fallback)
callCount := 0
calledModels := []string{}
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
calledModels = append(calledModels, provider+"/"+model)
switch callCount {
case 1:
// k1: rate limit
return nil, errors.New("status: 429 - rate limit")
case 2:
// k2: also rate limit (all zhipu keys exhausted)
return nil, errors.New("status: 429 - rate limit")
case 3:
// minimax: success
return &LLMResponse{Content: "success from minimax"}, nil
default:
return nil, errors.New("unexpected call")
}
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after failover to model fallback, got error: %v", err)
}
if callCount != 3 {
t.Errorf("expected 3 calls (k1 fail + k2 fail + minimax success), got %d", callCount)
}
if result.Response.Content != "success from minimax" {
t.Errorf("expected response from minimax, got: %s", result.Response.Content)
}
// Verify call order
if len(calledModels) != 3 {
t.Fatalf("expected 3 called models, got %d", len(calledModels))
}
if calledModels[0] != "zhipu/glm-4.7" {
t.Errorf("expected first call to zhipu/glm-4.7, got: %s", calledModels[0])
}
if calledModels[1] != "zhipu/glm-4.7__key_1" {
t.Errorf("expected second call to zhipu/glm-4.7__key_1, got: %s", calledModels[1])
}
if calledModels[2] != "minimax/minimax" {
t.Errorf("expected third call to minimax/minimax, got: %s", calledModels[2])
}
// Verify 2 failed attempts recorded
if len(result.Attempts) != 2 {
t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts))
}
// Both should be rate limit
for i, attempt := range result.Attempts {
if attempt.Reason != FailoverRateLimit {
t.Errorf("expected attempt %d to be rate_limit, got: %s", i, attempt.Reason)
}
}
}
// TestMultiKeyFailoverMixedErrors tests failover with different error types
func TestMultiKeyFailoverMixedErrors(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-4.7",
Fallbacks: []string{"glm-4.7__key_1", "glm-4.7__key_2"},
}
candidates := ResolveCandidates(cfg, "zhipu")
cooldown := NewCooldownTracker()
chain := NewFallbackChain(cooldown)
// Mock run function: different errors for each key
callCount := 0
mockRun := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
callCount++
switch callCount {
case 1:
// First: rate limit (retriable)
return nil, errors.New("status: 429 - rate limit")
case 2:
// Second: timeout (retriable)
return nil, errors.New("context deadline exceeded")
case 3:
// Third: success
return &LLMResponse{Content: "success from key3"}, nil
default:
return nil, errors.New("unexpected call")
}
}
result, err := chain.Execute(context.Background(), candidates, mockRun)
if err != nil {
t.Fatalf("expected success after 2 failovers, got error: %v", err)
}
if callCount != 3 {
t.Errorf("expected 3 calls, got %d", callCount)
}
// Verify both failed attempts were recorded
if len(result.Attempts) != 2 {
t.Errorf("expected 2 failed attempts, got %d", len(result.Attempts))
}
// First should be rate limit
if result.Attempts[0].Reason != FailoverRateLimit {
t.Errorf("expected first attempt to be rate_limit, got: %s", result.Attempts[0].Reason)
}
// Second should be timeout
if result.Attempts[1].Reason != FailoverTimeout {
t.Errorf("expected second attempt to be timeout, got: %s", result.Attempts[1].Reason)
}
}

View file

@ -157,8 +157,8 @@ func TestFallback_CooldownSkip(t *testing.T) {
ct, _ := newTestTracker(now)
fc := NewFallbackChain(ct)
// Put openai in cooldown
ct.MarkFailure("openai", FailoverRateLimit)
// Put openai/gpt-4 in cooldown (using ModelKey now)
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"),
@ -195,9 +195,9 @@ func TestFallback_AllInCooldown(t *testing.T) {
ct := NewCooldownTracker()
fc := NewFallbackChain(ct)
// Put all providers in cooldown
ct.MarkFailure("openai", FailoverRateLimit)
ct.MarkFailure("anthropic", FailoverBilling)
// Put all models in cooldown (using ModelKey now)
ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit)
ct.MarkFailure(ModelKey("anthropic", "claude"), FailoverBilling)
candidates := []FallbackCandidate{
makeCandidate("openai", "gpt-4"),
@ -273,12 +273,13 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
fc := NewFallbackChain(ct)
candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
modelKey := ModelKey("openai", "gpt-4")
attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
attempt++
if attempt == 1 {
ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere
ct.MarkFailure(modelKey, FailoverRateLimit) // simulate failure tracked elsewhere
}
return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
}
@ -287,7 +288,7 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ct.IsAvailable("openai") {
if !ct.IsAvailable(modelKey) {
t.Error("success should reset cooldown")
}
}

View file

@ -53,6 +53,14 @@ func NormalizeProvider(provider string) string {
return "zhipu"
case "google":
return "gemini"
case "alibaba-coding", "qwen-coding":
return "coding-plan"
case "alibaba-coding-anthropic":
return "coding-plan-anthropic"
case "qwen-international", "dashscope-intl":
return "qwen-intl"
case "dashscope-us":
return "qwen-us"
}
return p

View file

@ -73,6 +73,14 @@ func TestNormalizeProvider(t *testing.T) {
{"glm", "zhipu"},
{"google", "gemini"},
{"groq", "groq"},
// Alibaba Coding Plan aliases
{"alibaba-coding", "coding-plan"},
{"qwen-coding", "coding-plan"},
{"alibaba-coding-anthropic", "coding-plan-anthropic"},
// Qwen international aliases
{"qwen-international", "qwen-intl"},
{"dashscope-intl", "qwen-intl"},
{"dashscope-us", "qwen-us"},
{"", ""},
}

Some files were not shown because too many files have changed in this diff Show more