-
+
-
PicoClaw: Asisten AI Super Ringan berbasis Go
+
PicoClaw: Asisten AI Super Ringan berbasis Go
-
Perangkat Keras $10 · RAM <10MB · Boot <1 Detik · Ayo, Berangkat!
+
Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!
@@ -24,135 +24,125 @@
---
-> **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 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.
+**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu 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!
+**Berjalan di perangkat keras $10 dengan RAM <10MB** — hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
> [!CAUTION]
-> **🚨 KEAMANAN & SALURAN RESMI**
->
-> * **TANPA KRIPTO:** PicoClaw **TIDAK** memiliki token/koin resmi. Semua klaim di `pump.fun` atau platform trading lainnya adalah **PENIPUAN**.
+> **Peringatan Keamanan**
>
+> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. 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 (10–20MB) pada versi terbaru. Kami berencana untuk memprioritaskan optimasi sumber daya segera setelah fitur saat ini mencapai kondisi stabil.
+> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
+> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
+> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur 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-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 telah mencapai **25K Stars**!
-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-03-09 🎉 **v0.2.1 — Update terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline vision, penyimpanan memori JSONL, routing model.
-2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan launcher Web UI.
+2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
-2026-02-26 🎉 PicoClaw mencapai **20K bintang** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas diluncurkan.
+2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
-Berita lama...
+Berita sebelumnya...
-2026-02-16 🎉 PicoClaw mencapai 12K bintang dalam satu minggu! Peran maintainer komunitas dan [roadmap](ROADMAP.md) resmi diposting.
+2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
-2026-02-13 🎉 PicoClaw mencapai 5000 bintang dalam 4 hari! Roadmap Proyek dan pengaturan Grup Pengembang sedang berjalan.
+2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
-2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. 🦐 PicoClaw, Ayo Berangkat!
+2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
## ✨ Fitur
-🪶 **Super Ringan**: Penggunaan memori <10MB — 99% lebih kecil dari fungsionalitas inti OpenClaw.*
+🪶 **Super Ringan**: Penggunaan memori inti <10MB — 99% lebih kecil dari 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.
+⚡️ **Boot Secepat Kilat**: 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!
+🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
-🤖 **AI-Bootstrapped**: Implementasi Go-native secara otonom — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
+🤖 **AI-Bootstrapped**: Implementasi Go native murni — 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.
+🔌 **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.
+👁️ **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 10–20MB karena penggabungan fitur yang cepat. Optimasi sumber daya direncanakan. Perbandingan startup berdasarkan benchmark prosesor single-core 0,8GHz (lihat tabel di bawah)._
+_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
-| | OpenClaw | NanoBot | **PicoClaw** |
-| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
-| **Bahasa** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB*** |
-| **Startup**(0,8GHz core) | >500d | >30d | **<1d** |
-| **Biaya** | Mac Mini $599 | Kebanyakan Linux SBC ~$50 | **Semua Board Linux****Mulai dari $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **Bahasa** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Waktu Boot**(core 0,8GHz) | >500d | >30d | **<1d** |
+| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun****mulai $10** |
+
+
+> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
+
+
+
+
+
## 🦾 Demonstrasi
### 🛠️ Alur Kerja Asisten Standar
-
- 🧩 Full-Stack Engineer
- 🗂️ Pencatatan & Manajemen Perencanaan
- 🔎 Pencarian Web & Pembelajaran
-
-
-
-
-
-
-
- Develop • Deploy • Scale
- Jadwal • Otomasi • Memori
- Penemuan • Wawasan • Tren
-
+
+Mode Full-Stack Engineer
+Pencatatan & Perencanaan
+Pencarian Web & Pembelajaran
+
+
+
+
+
+
+
+Develop · Deploy · Scale
+Jadwal · Otomasi · Ingat
+Temukan · Wawasan · Tren
+
-### 📱 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!
-
-
-
### 🐜 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
+- $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 operasi 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 pengawasan cerdas
@@ -160,11 +150,15 @@ PicoClaw dapat di-deploy di hampir semua perangkat Linux!
## 📦 Instalasi
-### Instal dengan binary yang sudah dikompilasi
+### Unduh dari picoclaw.io (Direkomendasikan)
-Unduh binary untuk platform Anda dari halaman [Releases](https://github.com/sipeed/picoclaw/releases).
+Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
-### Instal dari source (fitur terbaru, disarankan untuk pengembangan)
+### Unduh binary yang sudah dikompilasi
+
+Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Build dari source (untuk pengembangan)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -172,79 +166,414 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build, tidak perlu instal
+# Build binary inti
make build
+# Build Web UI Launcher (diperlukan untuk mode WebUI)
+make build-launcher
+
# 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
+# 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.
+**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
+## 🚀 Panduan Memulai Cepat
-Untuk panduan lengkap, lihat dokumen di bawah. README ini hanya berisi panduan cepat.
+### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
-| 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 |
+WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
+
+**Opsi 1: Klik dua kali (Desktop)**
+
+Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
+
+**Opsi 2: Command line**
+
+```bash
+picoclaw-launcher
+# Buka http://localhost:18800 di browser Anda
+```
+
+> [!TIP]
+> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**Memulai:**
+
+Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
+
+Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
+
+
+Docker (alternatif)
+
+```bash
+# 1. Clone repo ini
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
+# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# Container mencetak "First-run setup complete." dan berhenti.
+
+# 3. Atur API key Anda
+vim docker/data/config.json
+
+# 4. Mulai
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# Buka http://localhost:18800
+```
+
+> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
+
+```bash
+# Cek log
+docker compose -f docker/docker-compose.yml logs -f
+
+# Hentikan
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# Update
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
+
+TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**Memulai:**
+
+Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
+
+Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
+
+### 📱 Android
+
+Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
+
+**Opsi 1: Termux (tersedia sekarang)**
+
+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 berikut:
+
+```bash
+# Unduh rilis terbaru
+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 # chroot menyediakan tata letak filesystem Linux standar
+```
+
+Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
+
+
+
+**Opsi 2: Instal APK (segera hadir)**
+
+APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
+
+
+Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)
+
+Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
+
+**1. Inisialisasi**
+
+```bash
+picoclaw onboard
+```
+
+Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
+
+**2. Konfigurasi** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
+
+**3. Chat**
+
+```bash
+# Pertanyaan satu kali
+picoclaw agent -m "What is 2+2?"
+
+# Mode interaktif
+picoclaw agent
+
+# Mulai gateway untuk integrasi aplikasi chat
+picoclaw gateway
+```
+
+
+
+## 🔌 Providers (LLM)
+
+PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
+
+| Provider | Protocol | API Key | Catatan |
+|----------|----------|---------|---------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
+| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
+| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
+| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+Deploy lokal (Ollama, vLLM, dll.)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
+
+
+
+## 💬 Channels (Aplikasi Chat)
+
+Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
+
+| Channel | Pengaturan | Protocol | Dokumentasi |
+|---------|------------|----------|-------------|
+| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
+| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
+| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
+| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
+| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
+| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
+| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
+| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
+| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
+| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
+| **WeCom Bot** | Sedang (webhook URL) | Webhook | [Panduan](docs/channels/wecom/wecom_bot/README.md) |
+| **WeCom App** | Sedang (corp credentials) | Webhook | [Panduan](docs/channels/wecom/wecom_app/README.md) |
+| **WeCom AI Bot** | Sedang (token + AES key) | WebSocket / Webhook | [Panduan](docs/channels/wecom/wecom_aibot/README.md) |
+| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
+| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
+| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
+| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
+| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
+
+> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
+
+Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
+
+## 🔧 Tools
+
+### 🔍 Pencarian Web
+
+PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
+
+| Mesin Pencari | API Key | Tier Gratis | Tautan |
+|--------------|---------|-------------|--------|
+| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
+| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
+| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
+| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
+| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
+| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
+
+### ⚙️ Tools Lainnya
+
+PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
+
+## 🎯 Skills
+
+Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
+
+**Instal skills dari ClawHub:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
+
+Tambahkan ke `config.json` Anda:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
## Bergabung dengan Jaringan Sosial Agent
-Hubungkan Picoclaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi.
+Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
**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 |
+| Perintah | Deskripsi |
+| -------------------------- | -------------------------------- |
+| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
+| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR |
| `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 model` | Lihat atau ubah model default |
-| `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 |
+| `picoclaw agent` | Mode chat interaktif |
+| `picoclaw gateway` | Mulai gateway |
+| `picoclaw status` | Tampilkan status |
+| `picoclaw version` | Tampilkan info versi |
+| `picoclaw model` | Lihat atau ganti model default |
+| `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
+### ⏰ 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
+* **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
+
+## 📚 Dokumentasi
+
+Untuk panduan lengkap di luar README ini:
+
+| Topik | Deskripsi |
+|-------|-----------|
+| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
+| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
+| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
+| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
+| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
+| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
+| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
+| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
+| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
+| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
+| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
## 🤝 Kontribusi & Roadmap
-PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. 🤗
+PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
-Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) lengkap kami.
+Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
Grup Pengguna:
-discord:
+Discord:
+
+WeChat:
+
-
diff --git a/README.it.md b/README.it.md
index bb460e8ce..dae541a17 100644
--- a/README.it.md
+++ b/README.it.md
@@ -1,9 +1,9 @@
-
+
-
PicoClaw: Assistente IA Ultra-Efficiente in Go
+
PicoClaw: Assistente IA Ultra-Efficiente in Go
-
Hardware da $10 · <10MB RAM · Boot in <1s · 皮皮虾,我们走!
+
Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!
@@ -24,135 +24,125 @@
---
-> **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 progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — 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.
+**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la 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!
+**Funziona su hardware da $10 con <10MB di RAM** — il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
> [!CAUTION]
-> **🚨 SICUREZZA & CANALI UFFICIALI**
+> **Avviso di Sicurezza**
>
-> * **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 (10–20MB) nelle ultime versioni. Prevediamo di dare priorità all'ottimizzazione delle risorse non appena il set di funzionalità corrente raggiungerà uno stato stabile.
+> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. 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 stati registrati da terze parti. Non fidarti di essi.
+> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
+> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
## 📢 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-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
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-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
-2026-02-26 🎉 PicoClaw ha raggiunto **20K stelle** in soli 17 giorni! Arrivate l'orchestrazione automatica dei canali e le interfacce di capacità.
+2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
Notizie precedenti...
-2026-02-16 🎉 PicoClaw ha raggiunto 12K stelle in una settimana! Ruoli di maintainer della community e [roadmap](ROADMAP.md) pubblicati ufficialmente.
+2026-02-16 🎉 PicoClaw supera 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-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi 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!
+2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
## ✨ Caratteristiche
-🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo delle funzionalità principali di OpenClaw.*
+🪶 **Ultra-Leggero**: Impronta di memoria <10MB — il 99% più piccolo rispetto a 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.
+⚡️ **Avvio Fulmineo**: 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!
+🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
-🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go in modo autonomo — 95% del core generato dall'Agent con perfezionamento umano nel ciclo.
+🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
-🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'agent.
+🔌 **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.
+👁️ **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 10–20MB 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)._
+_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. 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**(core 0,8 GHz) | >500s | >30s | **<1s** |
-| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux****A partire da $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **Linguaggio** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Avvio**(core 0,8 GHz) | >500s | >30s | **<1s** |
+| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux****a partire da $10** |
+
+
+> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
+
+
+
+
+
## 🦾 Dimostrazione
### 🛠️ Flussi di Lavoro Standard dell'Assistente
-
- 🧩 Ingegnere Full-Stack
- 🗂️ Gestione Log & Pianificazione
- 🔎 Ricerca Web & Apprendimento
-
-
-
-
-
-
-
- Sviluppa • Distribuisci • Scala
- Pianifica • Automatizza • Memorizza
- Scopri • Analizza • Tendenze
-
+
+Modalità Ingegnere Full-Stack
+Log & Pianificazione
+Ricerca Web & Apprendimento
+
+
+
+
+
+
+
+Sviluppa · Distribuisci · Scala
+Pianifica · Automatizza · Memorizza
+Scopri · Analizza · Tendenze
+
-### 📱 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!
-
-
-
### 🐜 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
+- $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 la sorveglianza intelligente
@@ -160,11 +150,15 @@ PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
## 📦 Installazione
-### Installa con binario precompilato
+### Scarica da picoclaw.io (Consigliato)
-Scarica il binario per la tua piattaforma dalla pagina delle [Releases](https://github.com/sipeed/picoclaw/releases).
+Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
-### Installa dai sorgenti (ultime funzionalità, consigliato per lo sviluppo)
+### Scarica il binario precompilato
+
+In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Compila dai sorgenti (per lo sviluppo)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -172,34 +166,348 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Compila, senza installare
+# Compila il binario core
make build
+# Compila il Web UI Launcher (necessario per la modalità WebUI)
+make build-launcher
+
# 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
+# 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.
+**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
+## 🚀 Guida Rapida
-Per guide dettagliate, consulta la documentazione qui sotto. Il README copre solo l'avvio rapido.
+### 🌐 WebUI Launcher (Consigliato per Desktop)
-| 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 |
+Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
+
+**Opzione 1: Doppio clic (Desktop)**
+
+Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
+
+**Opzione 2: Riga di comando**
+
+```bash
+picoclaw-launcher
+# Apri http://localhost:18800 nel browser
+```
+
+> [!TIP]
+> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**Per iniziare:**
+
+Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
+
+Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
+
+
+Docker (alternativa)
+
+```bash
+# 1. Clona questo repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
+# (si attiva solo quando sia config.json che workspace/ sono assenti)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# Il container stampa "First-run setup complete." e si ferma.
+
+# 3. Imposta le tue API key
+vim docker/data/config.json
+
+# 4. Avvia
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# Apri http://localhost:18800
+```
+
+> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
+
+```bash
+# Controlla i log
+docker compose -f docker/docker-compose.yml logs -f
+
+# Ferma
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# Aggiorna
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher (Consigliato per Headless / SSH)
+
+Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**Per iniziare:**
+
+Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
+
+Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
+
+### 📱 Android
+
+Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
+
+**Opzione 1: Termux (disponibile ora)**
+
+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 seguenti comandi:
+
+```bash
+# Scarica l'ultima release
+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 # chroot fornisce un layout standard del filesystem Linux
+```
+
+Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
+
+
+
+**Opzione 2: APK Install (prossimamente)**
+
+Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
+
+
+Terminal Launcher (per ambienti con risorse limitate)
+
+Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
+
+**1. Inizializza**
+
+```bash
+picoclaw onboard
+```
+
+Questo crea `~/.picoclaw/config.json` e la directory workspace.
+
+**2. Configura** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
+
+**3. Chatta**
+
+```bash
+# Domanda singola
+picoclaw agent -m "Quanto fa 2+2?"
+
+# Modalità interattiva
+picoclaw agent
+
+# Avvia il gateway per l'integrazione con app di chat
+picoclaw gateway
+```
+
+
+
+## 🔌 Provider (LLM)
+
+PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
+
+| Provider | Protocollo | API Key | Note |
+|----------|------------|---------|------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
+| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
+| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
+| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+Deploy locale (Ollama, vLLM, ecc.)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
+
+
+
+## 💬 Channel (App di Chat)
+
+Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
+
+| Channel | Configurazione | Protocollo | Docs |
+|---------|----------------|------------|------|
+| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
+| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
+| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
+| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
+| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
+| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
+| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
+| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
+| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
+| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
+| **WeCom Bot** | Medio (webhook URL) | Webhook | [Guida](docs/channels/wecom/wecom_bot/README.md) |
+| **WeCom App** | Medio (credenziali aziendali) | Webhook | [Guida](docs/channels/wecom/wecom_app/README.md) |
+| **WeCom AI Bot** | Medio (token + AES key) | WebSocket / Webhook | [Guida](docs/channels/wecom/wecom_aibot/README.md) |
+| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
+| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
+| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
+| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
+| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
+
+> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
+
+Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
+
+## 🔧 Strumenti
+
+### 🔍 Ricerca Web
+
+PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
+
+| Motore di Ricerca | API Key | Piano Gratuito | Link |
+|-------------------|---------|----------------|------|
+| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
+| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
+| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
+| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
+| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
+| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
+
+### ⚙️ Altri Strumenti
+
+PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
+
+## 🎯 Skill
+
+Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
+
+**Installa skill da ClawHub:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
+
+Aggiungi al tuo `config.json`:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
## Unisciti al Social Network degli Agent
@@ -212,12 +520,13 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
| Comando | Descrizione |
| ------------------------- | ---------------------------------- |
| `picoclaw onboard` | Inizializza config & workspace |
+| `picoclaw onboard weixin` | Connetti account WeChat tramite QR |
| `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 model` | Mostra o cambia il modello predefinito |
+| `picoclaw model` | Visualizza o cambia il modello predefinito |
| `picoclaw cron list` | Elenca tutti i job pianificati |
| `picoclaw cron add ...` | Aggiunge un job pianificato |
| `picoclaw cron disable` | Disabilita un job pianificato |
@@ -227,24 +536,43 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
| `picoclaw auth login` | Autenticazione con i provider |
-### Task Pianificati / Promemoria
+### ⏰ 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
+* **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
+
+## 📚 Documentazione
+
+Per guide dettagliate oltre questo README:
+
+| Argomento | Descrizione |
+|-----------|-------------|
+| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
+| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
+| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
+| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
+| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
+| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
+| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
+| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
+| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
+| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
+| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
## 🤝 Contribuisci & Roadmap
-Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. 🤗
+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.
+Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
Gruppi utenti:
-discord:
+Discord:
-
+WeChat:
+
diff --git a/README.ja.md b/README.ja.md
index e5a927505..3096d4022 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -3,7 +3,7 @@
PicoClaw: Go で書かれた超効率 AI アシスタント
- $10 ハードウェア · <10MB RAM · <1秒起動 · 行くぜ、シャコ!
+ $10 ハードウェア · 10MB RAM · ms 起動 · Let's Go, PicoClaw!
@@ -26,9 +26,9 @@
> **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。
-🦐 PicoClaw は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
+**PicoClaw** は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。**Go** でゼロからリビルドされ、「セルフブートストラッピング」プロセスで構築されました — AI Agent 自身がアーキテクチャの移行とコード最適化を推進しました。
-⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
+**$10 のハードウェアで 10MB 未満の RAM で動作** — OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
> [!CAUTION]
-> **🚨 セキュリティ&公式チャンネル**
+> **セキュリティに関する注意**
>
> * **暗号通貨なし:** PicoClaw には公式トークン/コインは**一切ありません**。`pump.fun` やその他の取引プラットフォームでの主張はすべて**詐欺**です。
->
> * **公式ドメイン:** **唯一**の公式サイトは **[picoclaw.io](https://picoclaw.io)**、企業サイトは **[sipeed.com](https://sipeed.com)** です。
-> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。
-> * **注意:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。
+> * **注意:** 多くの `.ai/.org/.com/.net/...` ドメインは第三者によって登録されています。信頼しないでください。
+> * **注記:** PicoClaw は初期開発段階にあり、未解決のネットワークセキュリティ問題がある可能性があります。v1.0 リリース前に本番環境へのデプロイは避けてください。
> * **注記:** PicoClaw は最近多くの PR をマージしており、最新バージョンではメモリフットプリントが大きくなる場合があります(10〜20MB)。機能セットが安定次第、リソース最適化を優先する予定です。
## 📢 ニュース
-2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的ゲートウェイホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
+2026-03-17 🚀 **v0.2.3 リリース!** システムトレイ UI(Windows & Linux)、サブエージェントステータス追跡(`spawn_status`)、実験的 Gateway ホットリロード、cron セキュリティゲート、セキュリティ修正 2 件。PicoClaw **25K ⭐** 達成!
-2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート!** MCP プロトコル対応、4 つの新チャネル(Matrix/IRC/WeCom/Discord Proxy)、3 つの新プロバイダー(Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。
+2026-03-09 🎉 **v0.2.1 — 史上最大のアップデート!** MCP プロトコル対応、4 つの新 Channel(Matrix/IRC/WeCom/Discord Proxy)、3 つの新 Provider(Kimi/Minimax/Avian)、ビジョンパイプライン、JSONL メモリストア、モデルルーティング。
-2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI ランチャー。
+2026-02-28 📦 **v0.2.0** リリース — Docker Compose 対応と Web UI Launcher。
-2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!チャネル自動オーケストレーションとケイパビリティインターフェースが実装されました。
+2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。
過去のニュース...
@@ -72,82 +71,71 @@
2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。
-2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
+2026-02-09 🎉 **PicoClaw リリース!** $10 ハードウェアで 10MB 未満の RAM で動く AI Agent を 1 日で構築。Let's Go, PicoClaw!
## ✨ 特徴
-🪶 **超軽量**: メモリフットプリント 10MB 未満 — OpenClaw のコア機能より 99% 小さい。*
+🪶 **超軽量**: コアメモリフットプリント 10MB 未満 — OpenClaw より 99% 小さい。*
💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
-⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒未満で起動。
+⚡️ **超高速起動**: 起動時間 400 倍高速。0.6GHz シングルコアでも 1 秒未満で起動。
-🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
+🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。どこでも動く!
-🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
+🤖 **AI ブートストラップ**: 純粋な Go ネイティブ実装 — コアコードの 95% が Agent によって生成され、人間によるレビューで調整。
-🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続してエージェント機能を拡張。
+🔌 **MCP 対応**: ネイティブ [Model Context Protocol](https://modelcontextprotocol.io/) 統合 — 任意の MCP サーバーに接続して Agent 機能を拡張。
-👁️ **ビジョンパイプライン**: 画像やファイルをエージェントに直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。
+👁️ **ビジョンパイプライン**: 画像やファイルを Agent に直接送信 — マルチモーダル LLM 向けの自動 base64 エンコーディング。
🧠 **スマートルーティング**: ルールベースのモデルルーティング — 簡単なクエリは軽量モデルへ、API コストを節約。
-_*最近のバージョンでは急速な機能マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_
+_*最近のバージョンでは急速な PR マージにより 10〜20MB になる場合があります。リソース最適化は計画中です。起動時間の比較は 0.8GHz シングルコアベンチマークに基づいています(下表参照)。_
-| | OpenClaw | NanoBot | **PicoClaw** |
-| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
-| **言語** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB*** |
-| **起動時間**(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
-| **コスト** | Mac Mini $599 | 大半の Linux SBC ~$50 | **あらゆる Linux ボード****最安 $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **言語** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **起動時間**(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
+| **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード****最安 $10** |
-> 📋 **[ハードウェア互換性リスト](docs/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
+
+
+> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
+
+
+
+
## 🦾 デモンストレーション
### 🛠️ スタンダードアシスタントワークフロー
-
- 🧩 フルスタックエンジニア
- 🗂️ ログ&計画管理
- 🔎 Web 検索&学習
-
-
-
-
-
-
-
- 開発 · デプロイ · スケール
- スケジュール · 自動化 · メモリ
- 発見 · インサイト · トレンド
-
+
+フルスタックエンジニアモード
+ログ&計画管理
+Web 検索&学習
+
+
+
+
+
+
+
+開発 · デプロイ · スケール
+スケジュール · 自動化 · メモリ
+発見 · インサイト · トレンド
+
-### 📱 古い Android スマホで動かす
-
-10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。クイックスタート:
-
-1. **[Termux](https://github.com/termux/termux-app) をインストール**([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)。
-2. **コマンドを実行**
-
-```bash
-# 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 # chroot で標準的な Linux ファイルシステムレイアウトを提供
-```
-
-その後「クイックスタート」セクションの手順に従って設定を完了してください!
-
-
-
### 🐜 革新的な省フットプリントデプロイ
PicoClaw はほぼすべての Linux デバイスにデプロイできます!
@@ -178,9 +166,12 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# ビルド(インストール不要)
+# コアバイナリをビルド
make build
+# Web UI Launcher をビルド(WebUI モードに必要)
+make build-launcher
+
# 複数プラットフォーム向けビルド
make build-all
@@ -193,20 +184,330 @@ make install
**Raspberry Pi Zero 2 W:** OS に合ったバイナリを使用してください:32-bit Raspberry Pi OS → `make build-linux-arm`、64-bit → `make build-linux-arm64`。または `make build-pi-zero` で両方をビルド。
-## 📚 ドキュメント
+## 🚀 クイックスタートガイド
-詳細なガイドは以下のドキュメントを参照してください。この README はクイックスタートのみをカバーしています。
+### 🌐 WebUI Launcher(デスクトップ向け推奨)
-| トピック | 説明 |
-|---------|------|
-| 🐳 [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード、クイックスタート設定 |
-| 💬 [チャットアプリ](docs/ja/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、DingTalk、LINE、Feishu、WeCom など |
-| ⚙️ [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、スキルソース、セキュリティサンドボックス、ハートビート |
-| 🔌 [プロバイダー&モデル](docs/ja/providers.md) | 20 以上の LLM プロバイダー、モデルルーティング、model_list 設定、プロバイダーアーキテクチャ |
-| 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
-| 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
-| 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー |
-| 📋 [ハードウェア互換性](docs/hardware-compatibility.md) | テスト済みボード、最小要件、ボードの追加方法 |
+WebUI Launcher はブラウザベースの設定・チャットインターフェースを提供します。コマンドラインの知識不要で、最も簡単に始められる方法です。
+
+**オプション 1: ダブルクリック(デスクトップ)**
+
+[picoclaw.io](https://picoclaw.io) からダウンロード後、`picoclaw-launcher`(Windows では `picoclaw-launcher.exe`)をダブルクリックしてください。ブラウザが自動的に `http://localhost:18800` を開きます。
+
+**オプション 2: コマンドライン**
+
+```bash
+picoclaw-launcher
+# ブラウザで http://localhost:18800 を開く
+```
+
+> [!TIP]
+> **リモートアクセス / Docker / VM:** すべてのインターフェースでリッスンするには `-public` フラグを追加してください:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**始め方:**
+
+WebUI を開いたら:**1)** Provider を設定(LLM API キーを追加)→ **2)** Channel を設定(例:Telegram)→ **3)** Gateway を起動 → **4)** チャット!
+
+WebUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
+
+
+Docker(代替手段)
+
+```bash
+# 1. このリポジトリをクローン
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. 初回実行 — docker/data/config.json を自動生成して終了
+# (config.json と workspace/ の両方が存在しない場合のみ実行)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# コンテナが "First-run setup complete." を出力して停止します。
+
+# 3. API キーを設定
+vim docker/data/config.json
+
+# 4. 起動
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# http://localhost:18800 を開く
+```
+
+> **Docker / VM ユーザー:** Gateway はデフォルトで `127.0.0.1` でリッスンします。ホストからアクセスできるようにするには `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`-public` フラグを使用してください。
+
+```bash
+# ログを確認
+docker compose -f docker/docker-compose.yml logs -f
+
+# 停止
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# 更新
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨)
+
+TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**始め方:**
+
+TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
+
+TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
+
+### 📱 Android
+
+10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
+
+**オプション 1: Termux(現在利用可能)**
+
+1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
+2. 以下のコマンドを実行:
+
+```bash
+# 最新リリースをダウンロード
+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 # chroot で標準的な Linux ファイルシステムレイアウトを提供
+```
+
+その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。
+
+
+
+**オプション 2: APK インストール(近日公開)**
+
+内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに!
+
+
+Terminal Launcher(リソース制約環境向け)
+
+`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
+
+**1. 初期化**
+
+```bash
+picoclaw onboard
+```
+
+`~/.picoclaw/config.json` とワークスペースディレクトリが作成されます。
+
+**2. 設定** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> 利用可能なすべてのオプションを含む完全な設定テンプレートは、リポジトリの `config/config.example.json` を参照してください。
+
+**3. チャット**
+
+```bash
+# ワンショット質問
+picoclaw agent -m "What is 2+2?"
+
+# インタラクティブモード
+picoclaw agent
+
+# チャットアプリ統合用 Gateway を起動
+picoclaw gateway
+```
+
+
+
+## 🔌 Provider(LLM)
+
+PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポートしています。`protocol/model` 形式を使用してください:
+
+| Provider | Protocol | API キー | 備考 |
+|----------|----------|---------|------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必須 | GPT-5.4、GPT-4o、o3 など |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必須 | Claude Opus 4.6、Sonnet 4.6 など |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必須 | Gemini 3 Flash、2.5 Pro など |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必須 | 200 以上のモデル、統合 API |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必須 | GLM-4.7、GLM-5 など |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必須 | DeepSeek-V3、DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | 必須 | Doubao、Ark モデル |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必須 | Qwen3、Qwen-Max など |
+| [Groq](https://console.groq.com/keys) | `groq/` | 必須 | 高速推論(Llama、Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必須 | Kimi モデル |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必須 | MiniMax モデル |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必須 | Mistral Large、Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必須 | NVIDIA ホスティングモデル |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必須 | 高速推論 |
+| [Novita AI](https://novita.ai/) | `novita/` | 必須 | 各種オープンモデル |
+| [Ollama](https://ollama.com/) | `ollama/` | 不要 | ローカルモデル、セルフホスト |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | 不要 | ローカルデプロイ、OpenAI 互換 |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 場合による | 100 以上の Provider のプロキシ |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必須 | エンタープライズ Azure デプロイ |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | デバイスコードログイン |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+ローカルデプロイ(Ollama、vLLM など)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。
+
+
+
+## 💬 Channel(チャットアプリ)
+
+17 以上のメッセージングプラットフォームで PicoClaw と会話できます:
+
+| Channel | セットアップ | Protocol | ドキュメント |
+|---------|------------|----------|------------|
+| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) |
+| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) |
+| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) |
+| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) |
+| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) |
+| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) |
+| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) |
+| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) |
+| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) |
+| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) |
+| **WeCom Bot** | 中級(webhook URL) | Webhook | [ガイド](docs/channels/wecom/wecom_bot/README.ja.md) |
+| **WeCom App** | 中級(corp 認証情報) | Webhook | [ガイド](docs/channels/wecom/wecom_app/README.ja.md) |
+| **WeCom AI Bot** | 中級(トークン + AES キー) | WebSocket / Webhook | [ガイド](docs/channels/wecom/wecom_aibot/README.ja.md) |
+| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) |
+| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) |
+| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) |
+| **Pico** | 簡単(有効化) | Native protocol | 内蔵 |
+| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 |
+
+> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
+
+Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
+
+## 🔧 ツール
+
+### 🔍 Web 検索
+
+PicoClaw は最新情報を提供するために Web を検索できます。`tools.web` で設定してください:
+
+| 検索エンジン | API キー | 無料枠 | リンク |
+|------------|---------|--------|-------|
+| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 |
+| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
+| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
+| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
+| [SearXNG](https://github.com/searxng/searxng) | 不要 | セルフホスト | 無料メタ検索エンジン |
+| [GLM Search](https://open.bigmodel.cn/) | 必須 | 場合による | Zhipu Web 検索 |
+
+### ⚙️ その他のツール
+
+PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。
+
+## 🎯 Skill
+
+Skill は Agent を拡張するモジュール型の機能です。ワークスペース内の `SKILL.md` ファイルから読み込まれます。
+
+**ClawHub から Skill をインストール:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**ClawHub トークンを設定**(オプション、レート制限を上げるため):
+
+`config.json` に追加:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。
+
+## 🔗 MCP(Model Context Protocol)
+
+PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポートしています — 任意の MCP サーバーに接続して、外部ツールやデータソースで Agent の機能を拡張できます。
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。
## エージェントソーシャルネットワークに参加
@@ -219,22 +520,23 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
| コマンド | 説明 |
| ------------------------- | ------------------------------ |
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
-| `picoclaw agent -m "..."` | エージェントとチャット |
+| `picoclaw onboard weixin` | WeChat アカウントを QR で接続 |
+| `picoclaw agent -m "..."` | Agent とチャット |
| `picoclaw agent` | インタラクティブチャットモード |
-| `picoclaw gateway` | ゲートウェイを起動 |
+| `picoclaw gateway` | Gateway を起動 |
| `picoclaw status` | ステータスを表示 |
| `picoclaw version` | バージョン情報を表示 |
+| `picoclaw model` | デフォルトモデルの表示・切替 |
| `picoclaw cron list` | スケジュールジョブ一覧 |
| `picoclaw cron add ...` | スケジュールジョブを追加 |
| `picoclaw cron disable` | スケジュールジョブを無効化 |
| `picoclaw cron remove` | スケジュールジョブを削除 |
-| `picoclaw skills list` | インストール済みスキル一覧 |
-| `picoclaw skills install` | スキルをインストール |
+| `picoclaw skills list` | インストール済み Skill 一覧 |
+| `picoclaw skills install` | Skill をインストール |
| `picoclaw migrate` | 旧バージョンからデータを移行 |
-| `picoclaw auth login` | プロバイダーへの認証 |
-| `picoclaw model` | デフォルトモデルの表示・切替 |
+| `picoclaw auth login` | Provider への認証 |
-### スケジュールタスク / リマインダー
+### ⏰ スケジュールタスク / リマインダー
PicoClaw は `cron` ツールによるスケジュールリマインダーと定期タスクをサポートしています:
@@ -242,16 +544,35 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定
* **定期タスク**: 「2時間ごとにリマインド」→ 2時間ごとにトリガー
* **Cron 式**: 「毎日9時にリマインド」→ cron 式を使用
+## 📚 ドキュメント
+
+この README を超えた詳細なガイドについては:
+
+| トピック | 説明 |
+|---------|------|
+| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード |
+| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド |
+| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス |
+| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 |
+| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
+| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
+| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 |
+| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
+| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
+| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill |
+| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 |
+
## 🤝 コントリビュート&ロードマップ
-PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
+PR 歓迎!コードベースは意図的に小さく読みやすくしています。
-完全な[コミュニティロードマップ](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)をご覧ください。
+[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。
開発者グループ構築中、最初の PR がマージされたら参加できます!
ユーザーグループ:
-discord:
+Discord:
-
+WeChat:
+
diff --git a/README.md b/README.md
index 994e4d13a..e25366ef8 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
-
+
-
PicoClaw: Ultra-Efficient AI Assistant in Go
+
PicoClaw: Ultra-Efficient AI Assistant in Go
-
$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!
+
$10 Hardware · 10MB RAM · ms Boot · Let's Go, PicoClaw!
@@ -24,141 +24,129 @@
---
-> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.
+> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com), written entirely in **Go** from scratch — not a fork of OpenClaw, NanoBot, or any other project.
-🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
+**PicoClaw** is an ultra-lightweight personal AI assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot). It was rebuilt from the ground up in **Go** through a "self-bootstrapping" process — the AI Agent itself drove the architecture migration and code optimization.
-⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
+**Runs on $10 hardware with <10MB RAM** — that's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
> [!CAUTION]
-> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
->
-> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
+> **Security Notice**
>
+> * **NO CRYPTO:** PicoClaw has **not** issued any official tokens or cryptocurrency. All claims on `pump.fun` or other trading platforms are **scams**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
-> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
-> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
-> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
+> * **BEWARE:** Many `.ai/.org/.com/.net/...` domains have been registered by third parties. Do not trust them.
+> * **NOTE:** PicoClaw is in early rapid development. There may be unresolved security issues. Do not deploy to production before v1.0.
+> * **NOTE:** PicoClaw has recently merged many PRs. Recent builds may use 10-20MB RAM. Resource optimization is planned after feature stabilization.
## 📢 News
-2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**!
+2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status query (`spawn_status`), experimental Gateway hot-reload, Cron security gating, and 2 security fixes. PicoClaw has reached **25K Stars**!
-2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing.
+2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, model routing.
-2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher.
+2026-02-28 📦 **v0.2.0** released with Docker Compose and Web UI Launcher support.
-2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.
+2026-02-26 🎉 PicoClaw hits **20K Stars** in just 17 days! Channel auto-orchestration and capability interfaces are live.
-Older news...
+Earlier news...
-2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted.
+2026-02-16 🎉 PicoClaw breaks 12K Stars in one week! Community maintainer roles and [Roadmap](ROADMAP.md) officially launched.
-2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway.
+2026-02-13 🎉 PicoClaw breaks 5000 Stars in 4 days! Project roadmap and developer groups in progress.
-2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
+2026-02-09 🎉 **PicoClaw Released!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. Let's Go, PicoClaw!
## ✨ Features
-🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.*
+🪶 **Ultra-lightweight**: Core memory footprint <10MB — 99% smaller than OpenClaw.*
-💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini.
+💰 **Minimal cost**: Efficient enough to run on $10 hardware — 98% cheaper than a Mac mini.
-⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core.
+⚡️ **Lightning-fast boot**: 400x faster startup. Boots in <1s even on a 0.6GHz single-core processor.
-🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
+🌍 **Truly portable**: Single binary across RISC-V, ARM, MIPS, and x86 architectures. One binary, runs everywhere!
-🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
+🤖 **AI-bootstrapped**: Pure Go native implementation — 95% of core code was generated by an Agent and fine-tuned through human-in-the-loop review.
-🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities.
+🔌 **MCP support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend Agent capabilities.
-👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs.
+👁️ **Vision pipeline**: Send images and files directly to the Agent — automatic base64 encoding for multimodal LLMs.
-🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
+🧠 **Smart routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
-_*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._
+_*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is planned. Boot speed comparison based on 0.8GHz single-core benchmarks (see table below)._
-| | OpenClaw | NanoBot | **PicoClaw** |
-| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
-| **Language** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB*** |
-| **Startup**(0.8GHz core) | >500s | >30s | **<1s** |
-| **Cost** | Mac Mini $599 | Most Linux SBC ~$50 | **Any Linux Board****As low as $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **Language** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Boot time**(0.8GHz core) | >500s | >30s | **<1s** |
+| **Cost** | Mac Mini $599 | Most Linux boards ~$50 | **Any Linux board****from $10** |
-> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
+
+> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
+
+
+
## 🦾 Demonstration
### 🛠️ Standard Assistant Workflows
-
- 🧩 Full-Stack Engineer
- 🗂️ Logging & Planning Management
- 🔎 Web Search & Learning
-
-
-
-
-
-
-
- Develop • Deploy • Scale
- Schedule • Automate • Memory
- Discovery • Insights • Trends
-
+
+Full-Stack Engineer Mode
+Logging & Planning
+Web Search & Learning
+
+
+
+
+
+
+
+Develop · Deploy · Scale
+Schedule · Automate · Remember
+Discover · Insights · Trends
+
-### 📱 Run on old Android Phones
+### 🐜 Innovative Low-Footprint Deployment
-Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+PicoClaw can be deployed on virtually any Linux device!
-1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play).
-2. **Execute cmds**
-
-```bash
-# Download the latest release from 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 # chroot provides a standard Linux filesystem layout
-```
-
-And then follow the instructions in the "Quick Start" section to complete the configuration!
-
-
-
-### 🐜 Innovative Low-Footprint Deploy
-
-PicoClaw can be deployed on almost any Linux device!
-
-- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
-- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
-- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) edition, for a minimal home assistant
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), for automated server operations
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), for smart surveillance
-🌟 More Deployment Cases Await!
+🌟 More Deployment Cases Await!
## 📦 Install
@@ -178,24 +166,59 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build, no need to install
+# Build core binary
make build
+# Build Web UI Launcher (required for WebUI mode)
+make build-launcher
+
# Build for multiple platforms
make build-all
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
-# Build And Install
+# Build and install
make install
```
-**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both.
+**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Or run `make build-pi-zero` to build both.
-## 📚 Documentation
+## 🚀 Quick Start Guide
-For detailed guides, see the docs below. The README covers quick start only.
+### 🌐 WebUI Launcher (Recommended for Desktop)
+
+The WebUI Launcher provides a browser-based interface for configuration and chat. This is the easiest way to get started — no command-line knowledge required.
+
+**Option 1: Double-click (Desktop)**
+
+After downloading from [picoclaw.io](https://picoclaw.io), double-click `picoclaw-launcher` (or `picoclaw-launcher.exe` on Windows). Your browser will open automatically at `http://localhost:18800`.
+
+**Option 2: Command line**
+
+```bash
+picoclaw-launcher
+# Open http://localhost:18800 in your browser
+```
+
+> [!TIP]
+> **Remote access / Docker / VM:** Add the `-public` flag to listen on all interfaces:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**Getting started:**
+
+Open the WebUI, then: **1)** Configure a Provider (add your LLM API key) -> **2)** Configure a Channel (e.g., Telegram) -> **3)** Start the Gateway -> **4)** Chat!
+
+For detailed WebUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
+
+
+Docker (alternative)
```bash
# 1. Clone this repo
@@ -203,61 +226,81 @@ 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
+# (only triggers when both config.json and workspace/ are missing)
+docker compose -f docker/docker-compose.yml --profile launcher 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.
+vim docker/data/config.json
# 4. Start
-docker compose -f docker/docker-compose.yml --profile gateway up -d
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# Open http://localhost:18800
```
-> [!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`.
+> **Docker / VM users:** The Gateway listens on `127.0.0.1` by default. Set `PICOCLAW_GATEWAY_HOST=0.0.0.0` or use the `-public` flag to make it accessible from the host.
```bash
-# 5. Check logs
-docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+# Check logs
+docker compose -f docker/docker-compose.yml logs -f
-# 6. Stop
-docker compose -f docker/docker-compose.yml --profile gateway down
-```
+# Stop
+docker compose -f docker/docker-compose.yml --profile launcher 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
+# Update
+docker compose -f docker/docker-compose.yml pull
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.
+### 💻 TUI Launcher (Recommended for Headless / SSH)
-### Agent Mode (One-shot)
+The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
```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
+picoclaw-launcher-tui
```
-### Update
+
+
+
+
+**Getting started:**
+
+Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
+
+For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
+
+### 📱 Android
+
+Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
+
+**Option 1: Termux (available now)**
+
+1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
+2. Run the following commands:
```bash
-docker compose -f docker/docker-compose.yml pull
-docker compose -f docker/docker-compose.yml --profile gateway up -d
+# Download the latest release
+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 # chroot provides a standard Linux filesystem layout
```
-### 🚀 Quick Start
+Then follow the Terminal Launcher section below to complete configuration.
-> [!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).
+
+
+**Option 2: APK Install (coming soon)**
+
+A standalone Android APK with built-in WebUI is in development. Stay tuned!
+
+
+Terminal Launcher (for resource-constrained environments)
+
+For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
**1. Initialize**
@@ -265,1166 +308,271 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
picoclaw onboard
```
+This creates `~/.picoclaw/config.json` and the workspace directory.
+
**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_name": "gpt-5.4"
}
},
"model_list": [
{
- "model_name": "ark-code-latest",
- "model": "volcengine/ark-code-latest",
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
"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) |
-| **Weixin** | Easy (Native QR scan) |
-| **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) |
-
-
-Telegram (Recommended)
-
-**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.
-
-
-
-
-Discord
-
-**1. Create a bot**
-
-* Go to
-* 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
-```
-
-
-
-
-WhatsApp (native via whatsmeow)
-
-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 `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
-
-
-
-
-Weixin (WeChat Personal)
-
-PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
-
-**1. Login**
-Run the interactive QR login flow:
-```bash
-picoclaw onboard weixin
-```
-Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
-
-**2. Configure**
-(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
-```json
-{
- "channels": {
- "weixin": {
- "enabled": true,
- "token": "YOUR_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-```
-
-**3. Run**
-```bash
-picoclaw gateway
-```
-
-
-
-
-QQ
-
-**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
-```
-
-
-
-
-DingTalk
-
-**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
-```
-
-
-
-Matrix
-
-**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).
-
-
-
-
-LINE
-
-**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.
-
-
-
-
-WeCom (企业微信)
-
-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.
-
-
-
-## Join the Agent Social Network
-
-Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
-
-**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
-
-## ⚙️ Configuration
-
-Config file: `~/.picoclaw/config.json`
-
-### Environment Variables
-
-You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
-
-| Variable | Description | Default Path |
-|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
-| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
-| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
-
-**Examples:**
-
-```bash
-# Run picoclaw using a specific config file
-# The workspace path will be read from within that config file
-PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
-
-# Run picoclaw with all its data stored in /opt/picoclaw
-# Config will be loaded from the default ~/.picoclaw/config.json
-# Workspace will be created at /opt/picoclaw/workspace
-PICOCLAW_HOME=/opt/picoclaw picoclaw agent
-
-# Use both for a fully customized setup
-PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
-```
-
-### Workspace Layout
-
-PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
-
-```
-~/.picoclaw/workspace/
-├── sessions/ # Conversation sessions and history
-├── memory/ # Long-term memory (MEMORY.md)
-├── state/ # Persistent state (last channel, etc.)
-├── cron/ # Scheduled jobs database
-├── skills/ # Workspace-specific skills
-├── AGENT.md # Structured agent definition and system prompt
-├── SOUL.md # Agent soul
-├── USER.md # User profile and preferences for this workspace
-├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
-└── ...
-```
-
-### Skill Sources
-
-By default, skills are loaded from:
-
-1. `~/.picoclaw/workspace/skills` (workspace)
-2. `~/.picoclaw/skills` (global)
-3. `/skills` (builtin)
-
-For advanced/test setups, you can override the builtin skills root with:
-
-```bash
-export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
-```
-
-### Unified Command Execution Policy
-
-- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
-- 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.
-### 🔒 Security Sandbox
-
-PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
-
-#### Default Configuration
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "restrict_to_workspace": true
- }
- }
-}
-```
-
-| Option | Default | Description |
-| ----------------------- | ----------------------- | ----------------------------------------- |
-| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
-| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
-
-#### Protected Tools
-
-When `restrict_to_workspace: true`, the following tools are sandboxed:
-
-| Tool | Function | Restriction |
-| ------------- | ---------------- | -------------------------------------- |
-| `read_file` | Read files | Only files within workspace |
-| `write_file` | Write files | Only files within workspace |
-| `list_dir` | List directories | Only directories within workspace |
-| `edit_file` | Edit files | Only files within workspace |
-| `append_file` | Append to files | Only files within workspace |
-| `exec` | Execute commands | Command paths must be within workspace |
-
-#### Additional Exec Protection
-
-Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
-
-* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
-* `format`, `mkfs`, `diskpart` — Disk formatting
-* `dd if=` — Disk imaging
-* Writing to `/dev/sd[a-z]` — Direct disk writes
-* `shutdown`, `reboot`, `poweroff` — System shutdown
-* Fork bomb `:(){ :|:& };:`
-
-#### Error Examples
-
-```
-[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)}
-```
-
-#### Disabling Restrictions (Security Risk)
-
-If you need the agent to access paths outside the workspace:
-
-**Method 1: Config file**
-
-```json
-{
- "agents": {
- "defaults": {
- "restrict_to_workspace": false
- }
- }
-}
-```
-
-**Method 2: Environment variable**
-
-```bash
-export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
-```
-
-> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only.
-
-#### Security Boundary Consistency
-
-The `restrict_to_workspace` setting applies consistently across all execution paths:
-
-| Execution Path | Security Boundary |
-| ---------------- | ---------------------------- |
-| Main Agent | `restrict_to_workspace` ✅ |
-| Subagent / Spawn | Inherits same restriction ✅ |
-| Heartbeat tasks | Inherits same restriction ✅ |
-
-All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
-
-### Heartbeat (Periodic Tasks)
-
-PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:
-
-```markdown
-# Periodic Tasks
-
-- Check my email for important messages
-- Review my calendar for upcoming events
-- Check the weather forecast
-```
-
-The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools.
-
-#### Async Tasks with Spawn
-
-For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**:
-
-```markdown
-# Periodic Tasks
-
-## Quick Tasks (respond directly)
-
-- Report current time
-
-## Long Tasks (use spawn for async)
-
-- Search the web for AI news and summarize
-- Check email and report important messages
-```
-
-**Key behaviors:**
-
-| Feature | Description |
-| ----------------------- | --------------------------------------------------------- |
-| **spawn** | Creates async subagent, doesn't block heartbeat |
-| **Independent context** | Subagent has its own context, no session history |
-| **message tool** | Subagent communicates with user directly via message tool |
-| **Non-blocking** | After spawning, heartbeat continues to next task |
-
-#### How Subagent Communication Works
-
-```
-Heartbeat triggers
- ↓
-Agent reads HEARTBEAT.md
- ↓
-For long task: spawn subagent
- ↓ ↓
-Continue to next task Subagent works independently
- ↓ ↓
-All tasks done Subagent uses "message" tool
- ↓ ↓
-Respond HEARTBEAT_OK User receives result directly
-```
-
-The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.
-
-**Configuration:**
-
-```json
-{
- "heartbeat": {
- "enabled": true,
- "interval": 30
- }
-}
-```
-
-| Option | Default | Description |
-| ---------- | ------- | ---------------------------------- |
-| `enabled` | `true` | Enable/disable heartbeat |
-| `interval` | `30` | Check interval in minutes (min: 5) |
-
-**Environment variables:**
-
-* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
-* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
-
-### Providers
-
-> [!NOTE]
-> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
-
-| Provider | Purpose | Get API Key |
-| ------------ | --------------------------------------- | ------------------------------------------------------------ |
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
-| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
-| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
-| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
-
-### Model Configuration (model_list)
-
-> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
-
-This design also enables **multi-agent support** with flexible provider selection:
-
-- **Different agents, different providers**: Each agent can use its own LLM provider
-- **Model fallbacks**: Configure primary and fallback models for resilience
-- **Load balancing**: Distribute requests across multiple endpoints
-- **Centralized configuration**: Manage all providers in one place
-
-#### 📋 All Supported Vendors
-
-| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
-| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
-| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
-| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
-| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
-| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
-| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
-| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
-| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
-| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
-
-#### Basic Configuration
-
-```json
-{
- "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": "sk-your-openai-key"
- },
- {
- "model_name": "claude-sonnet-4.6",
- "model": "anthropic/claude-sonnet-4.6",
- "api_key": "sk-ant-your-key"
- },
- {
- "model_name": "glm-4.7",
- "model": "zhipu/glm-4.7",
- "api_key": "your-zhipu-key"
- }
- ],
- "agents": {
- "defaults": {
- "model": "gpt-5.4"
- }
- }
-}
-```
-
-#### Vendor-Specific Examples
-
-**OpenAI**
-
-```json
-{
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_key": "sk-..."
-}
-```
-
-**VolcEngine (Doubao)**
-
-```json
-{
- "model_name": "ark-code-latest",
- "model": "volcengine/ark-code-latest",
- "api_key": "sk-..."
-}
-```
-
-**智谱 AI (GLM)**
-
-```json
-{
- "model_name": "glm-4.7",
- "model": "zhipu/glm-4.7",
- "api_key": "your-key"
-}
-```
-
-**DeepSeek**
-
-```json
-{
- "model_name": "deepseek-chat",
- "model": "deepseek/deepseek-chat",
- "api_key": "sk-..."
-}
-```
-
-**Anthropic (with API key)**
-
-```json
-{
- "model_name": "claude-sonnet-4.6",
- "model": "anthropic/claude-sonnet-4.6",
- "api_key": "sk-ant-your-key"
-}
-```
-
-> Run `picoclaw auth login --provider anthropic` to paste your API token.
-
-**Anthropic Messages API (native format)**
-
-For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
-
-```json
-{
- "model_name": "claude-opus-4-6",
- "model": "anthropic-messages/claude-opus-4-6",
- "api_key": "sk-ant-your-key",
- "api_base": "https://api.anthropic.com"
-}
-```
-
-> Use `anthropic-messages` protocol when:
-> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
-> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
-> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
->
-> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
-
-**Ollama (local)**
-
-```json
-{
- "model_name": "llama3",
- "model": "ollama/llama3"
-}
-```
-
-**Custom Proxy/API**
-
-```json
-{
- "model_name": "my-custom-model",
- "model": "openai/custom-model",
- "api_base": "https://my-proxy.com/v1",
- "api_key": "sk-...",
- "request_timeout": 300
-}
-```
-
-**LiteLLM Proxy**
-
-```json
-{
- "model_name": "lite-gpt4",
- "model": "litellm/lite-gpt4",
- "api_base": "http://localhost:4000/v1",
- "api_key": "sk-..."
-}
-```
-
-PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
-
-#### Load Balancing
-
-Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
-
-```json
-{
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_base": "https://api1.example.com/v1",
- "api_key": "sk-key1"
- },
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_base": "https://api2.example.com/v1",
- "api_key": "sk-key2"
}
]
}
```
-#### Migration from Legacy `providers` Config
+> See `config/config.example.json` in the repo for a complete configuration template with all available options.
-The old `providers` configuration is **deprecated** but still supported for backward compatibility.
+**3. Chat**
-**Old Config (deprecated):**
+```bash
+# One-shot question
+picoclaw agent -m "What is 2+2?"
-```json
-{
- "providers": {
- "zhipu": {
- "api_key": "your-key",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- },
- "agents": {
- "defaults": {
- "provider": "zhipu",
- "model": "glm-4.7"
- }
- }
-}
+# Interactive mode
+picoclaw agent
+
+# Start gateway for chat app integration
+picoclaw gateway
```
-**New Config (recommended):**
+
+## 🔌 Providers (LLM)
+
+PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use the `protocol/model` format:
+
+| Provider | Protocol | API Key | Notes |
+|----------|----------|---------|-------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Required | GPT-5.4, GPT-4o, o3, etc. |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Required | Claude Opus 4.6, Sonnet 4.6, etc. |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Required | Gemini 3 Flash, 2.5 Pro, etc. |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Required | 200+ models, unified API |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Required | GLM-4.7, GLM-5, etc. |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Required | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | Required | Doubao, Ark models |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Required | Qwen3, Qwen-Max, etc. |
+| [Groq](https://console.groq.com/keys) | `groq/` | Required | Fast inference (Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Required | Kimi models |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Required | MiniMax models |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Required | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Required | NVIDIA hosted models |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Required | Fast inference |
+| [Novita AI](https://novita.ai/) | `novita/` | Required | Various open models |
+| [Ollama](https://ollama.com/) | `ollama/` | Not needed | Local models, self-hosted |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | Not needed | Local deployment, OpenAI-compatible |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varies | Proxy for 100+ providers |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+Local deployment (Ollama, vLLM, etc.)
+
+**Ollama:**
```json
{
"model_list": [
{
- "model_name": "glm-4.7",
- "model": "zhipu/glm-4.7",
- "api_key": "your-key"
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
}
- ],
- "agents": {
- "defaults": {
- "model": "glm-4.7"
- }
- }
+ ]
}
```
-For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
-
-### Provider Architecture
-
-PicoClaw routes providers by protocol family:
-
-- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
-- Anthropic protocol: Claude-native API behavior.
-- Codex/OAuth path: OpenAI OAuth/token authentication route.
-
-This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
-
-
-Zhipu
-
-**1. Get API key and base URL**
-
-* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
-
-**2. Configure**
-
+**vLLM:**
```json
{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
}
- },
- "providers": {
- "zhipu": {
- "api_key": "Your API Key",
- "api_base": "https://open.bigmodel.cn/api/paas/v4"
- }
- }
+ ]
}
```
-**3. Run**
+For full provider configuration details, see [Providers & Models](docs/providers.md).
+
+
+
+## 💬 Channels (Chat Apps)
+
+Talk to your PicoClaw through 17+ messaging platforms:
+
+| Channel | Setup | Protocol | Docs |
+|---------|-------|----------|------|
+| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
+| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
+| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
+| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
+| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
+| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
+| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
+| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
+| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
+| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
+| **WeCom Bot** | Medium (webhook URL) | Webhook | [Guide](docs/channels/wecom/wecom_bot/README.md) |
+| **WeCom App** | Medium (corp credentials) | Webhook | [Guide](docs/channels/wecom/wecom_app/README.md) |
+| **WeCom AI Bot** | Medium (token + AES key) | WebSocket / Webhook | [Guide](docs/channels/wecom/wecom_aibot/README.md) |
+| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
+| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
+| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
+| **Pico** | Easy (enable) | Native protocol | Built-in |
+| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
+
+> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
+
+For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
+
+## 🔧 Tools
+
+### 🔍 Web Search
+
+PicoClaw can search the web to provide up-to-date information. Configure in `tools.web`:
+
+| Search Engine | API Key | Free Tier | Link |
+|--------------|---------|-----------|------|
+| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
+| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
+| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
+| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
+| [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine |
+| [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search |
+
+### ⚙️ Other Tools
+
+PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details.
+
+## 🎯 Skills
+
+Skills are modular capabilities that extend your Agent. They are loaded from `SKILL.md` files in your workspace.
+
+**Install skills from ClawHub:**
```bash
-picoclaw agent -m "Hello"
+picoclaw skills search "web scraping"
+picoclaw skills install
```
-
-
-
-Full config example
+**Configure ClawHub token** (optional, for higher rate limits):
+Add to your `config.json`:
```json
{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5"
- }
- },
- "session": {
- "dm_scope": "per-channel-peer",
- "backlog_limit": 20
- },
- "providers": {
- "openrouter": {
- "api_key": "sk-or-v1-xxx"
- },
- "groq": {
- "api_key": "gsk_xxx"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "123456:ABC...",
- "allow_from": ["123456789"]
- },
- "discord": {
- "enabled": true,
- "token": "",
- "allow_from": [""]
- },
- "whatsapp": {
- "enabled": false,
- "bridge_url": "ws://localhost:3001",
- "use_native": false,
- "session_store_path": "",
- "allow_from": []
- },
- "feishu": {
- "enabled": false,
- "app_id": "cli_xxx",
- "app_secret": "xxx",
- "encrypt_key": "",
- "verification_token": "",
- "allow_from": []
- },
- "qq": {
- "enabled": false,
- "app_id": "",
- "app_secret": "",
- "allow_from": []
- }
- },
"tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "BSA...",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- },
- "perplexity": {
- "enabled": false,
- "api_key": "",
- "max_results": 5
- },
- "searxng": {
- "enabled": false,
- "base_url": "http://localhost:8888",
- "max_results": 5
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
}
- },
- "cron": {
- "exec_timeout_minutes": 5
}
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
}
}
```
-
+For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool).
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect any MCP server to extend your Agent's capabilities with external tools and data sources.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool).
+
+## Join the Agent Social Network
+
+Connect PicoClaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
+
+**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ CLI Reference
-| Command | Description |
-| ------------------------- | ----------------------------- |
-| `picoclaw onboard` | Initialize config & workspace |
+| Command | Description |
+| ------------------------- | -------------------------------- |
+| `picoclaw onboard` | Initialize config & workspace |
| `picoclaw onboard weixin` | Connect WeChat account via QR |
-| `picoclaw agent -m "..."` | Chat with the agent |
-| `picoclaw agent` | Interactive chat mode |
-| `picoclaw gateway` | Start the gateway |
-| `picoclaw status` | Show status |
-| `picoclaw version` | Show version info |
-| `picoclaw model` | Show or change default model |
-| `picoclaw cron list` | List all scheduled jobs |
-| `picoclaw cron add ...` | Add a scheduled job |
-| `picoclaw cron disable` | Disable a scheduled job |
-| `picoclaw cron remove` | Remove a scheduled job |
-| `picoclaw skills list` | List installed skills |
-| `picoclaw skills install` | Install a skill |
+| `picoclaw agent -m "..."` | Chat with the agent |
+| `picoclaw agent` | Interactive chat mode |
+| `picoclaw gateway` | Start the gateway |
+| `picoclaw status` | Show status |
+| `picoclaw version` | Show version info |
+| `picoclaw model` | View or switch the default model |
+| `picoclaw cron list` | List all scheduled jobs |
+| `picoclaw cron add ...` | Add a scheduled job |
+| `picoclaw cron disable` | Disable a scheduled job |
+| `picoclaw cron remove` | Remove a scheduled job |
+| `picoclaw skills list` | List installed skills |
+| `picoclaw skills install` | Install a skill |
| `picoclaw migrate` | Migrate data from older versions |
-| `picoclaw auth login` | Authenticate with providers |
+| `picoclaw auth login` | Authenticate with providers |
-### Scheduled Tasks / Reminders
+### ⏰ Scheduled Tasks / Reminders
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
-* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
-* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
-* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
+* **One-time reminders**: "Remind me in 10 minutes" -> triggers once after 10min
+* **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours
+* **Cron expressions**: "Remind me at 9am daily" -> uses cron expression
+
+## 📚 Documentation
+
+For detailed guides beyond this README:
+
+| Topic | Description |
+|-------|-------------|
+| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes |
+| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides |
+| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox |
+| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration |
+| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
+| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks |
+| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls |
+| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle |
+| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
+| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills |
+| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements |
## 🤝 Contribute & Roadmap
-PRs welcome! The codebase is intentionally small and readable. 🤗
+PRs welcome! The codebase is intentionally small and readable.
-See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
+See our [Community Roadmap](https://github.com/sipeed/picoclaw/issues/988) and [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Developer group building, join after your first merged PR!
User Groups:
-discord:
+Discord:
WeChat:
diff --git a/README.pt-br.md b/README.pt-br.md
index c1df570a5..3c039f190 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -1,9 +1,9 @@
-
+
-
PicoClaw: Assistente de IA Ultra-Eficiente em Go
+
PicoClaw: Assistente de IA Ultra-Eficiente em Go
-
Hardware de $10 · <10MB de RAM · Boot em <1s · 皮皮虾,我们走!
+
Hardware de $10 · 10MB de RAM · Boot em ms · Let's Go, PicoClaw!
@@ -24,149 +24,137 @@
---
-> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
+> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com), escrito inteiramente em **Go** do zero — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
-🦐 PicoClaw é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em Go por meio de um processo de auto-inicialização (self-bootstrapping), onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
+**PicoClaw** é um assistente de IA pessoal ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot). Foi reconstruído do zero em **Go** por meio de um processo de "auto-bootstrapping" — o próprio AI Agent conduziu a migração de arquitetura e a otimização do código.
-⚡️ Roda em hardware de $10 com <10MB de RAM: Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
+**Roda em hardware de $10 com menos de 10MB de RAM** — isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
> [!CAUTION]
-> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**
+> **Aviso de Segurança**
>
-> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.
->
-> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**
-> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros.
-> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.
-> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10–20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.
+> * **SEM CRIPTO:** O PicoClaw **não** emitiu nenhum token oficial ou criptomoeda. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **golpes**.
+> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é **[sipeed.com](https://sipeed.com)**
+> * **ATENÇÃO:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros. Não confie neles.
+> * **NOTA:** O PicoClaw está em desenvolvimento rápido inicial. Podem existir problemas de segurança não resolvidos. Não implante em produção antes da v1.0.
+> * **NOTA:** O PicoClaw mesclou muitos PRs recentemente. Builds recentes podem usar 10-20MB de RAM. A otimização de recursos está planejada após a estabilização de funcionalidades.
## 📢 Novidades
-2026-03-17 🚀 **v0.2.3 Lançado!** Interface de bandeja do sistema (Windows & Linux), rastreamento de status de sub-agentes (`spawn_status`), hot-reload experimental do gateway, portões de segurança para cron e 2 correções de segurança. PicoClaw agora com **25K ⭐**!
+2026-03-17 🚀 **v0.2.3 Lançada!** UI na bandeja do sistema (Windows e Linux), consulta de status de sub-agent (`spawn_status`), hot-reload experimental do Gateway, controle de segurança do Cron e 2 correções de segurança. O PicoClaw atingiu **25K Stars**!
-2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos canais (Matrix/IRC/WeCom/Discord Proxy), 3 novos provedores (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL e roteamento de modelos.
+2026-03-09 🎉 **v0.2.1 — Maior atualização até agora!** Suporte ao protocolo MCP, 4 novos channels (Matrix/IRC/WeCom/Discord Proxy), 3 novos providers (Kimi/Minimax/Avian), pipeline de visão, armazenamento de memória JSONL, roteamento de modelos.
-2026-02-28 📦 **v0.2.0** lançado com suporte a Docker Compose e launcher Web UI.
+2026-02-28 📦 **v0.2.0** lançada com suporte a Docker Compose e Web UI Launcher.
-2026-02-26 🎉 PicoClaw atingiu **20K stars** em apenas 17 dias! Orquestração automática de canais e interfaces de capacidade implementadas.
+2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis.
-Novidades anteriores...
+Notícias anteriores...
-2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Papéis de maintainers da comunidade e [roadmap](ROADMAP.md) publicados oficialmente.
+2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente.
-2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Roadmap do Projeto e Grupo de Desenvolvedores em preparação.
+2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento.
-2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
+2026-02-09 🎉 **PicoClaw Lançado!** Construído em 1 dia para levar AI Agents a hardware de $10 com menos de 10MB de RAM. Let's Go, PicoClaw!
## ✨ Funcionalidades
-🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o OpenClaw para funcionalidades essenciais.*
+🪶 **Ultra-leve**: Footprint de memória do núcleo <10MB — 99% menor que o OpenClaw.*
-💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
+💰 **Custo mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
-⚡️ **Inicialização Relâmpago**: Tempo de inicialização 400X mais rápido, boot em <1 segundo mesmo em CPU single-core de 0.6GHz.
+⚡️ **Boot ultrarrápido**: Inicialização 400x mais rápida. Boot em menos de 1s mesmo em um processador single-core de 0,6GHz.
-🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
+🌍 **Verdadeiramente portátil**: Binário único para arquiteturas RISC-V, ARM, MIPS e x86. Um binário, roda em qualquer lugar!
-🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
+🤖 **Bootstrapped por IA**: Implementação nativa pura em Go — 95% do código principal foi gerado por um Agent e refinado por revisão humana.
-🔌 **Suporte MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do agente.
+🔌 **Suporte a MCP**: Integração nativa com o [Model Context Protocol](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do Agent.
-👁️ **Pipeline de Visão**: Envie imagens e arquivos diretamente ao agente — codificação base64 automática para LLMs multimodais.
+👁️ **Pipeline de visão**: Envie imagens e arquivos diretamente ao Agent — codificação base64 automática para LLMs multimodais.
-🧠 **Roteamento Inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.
+🧠 **Roteamento inteligente**: Roteamento de modelos baseado em regras — consultas simples vão para modelos leves, economizando custos de API.
-_*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalidades. Otimização de recursos está planejada. Comparação de inicialização baseada em benchmarks de single-core a 0.8GHz (veja tabela abaixo)._
+_*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimização de recursos está planejada. Comparação de velocidade de boot baseada em benchmarks de single-core a 0,8GHz (veja tabela abaixo)._
-| | OpenClaw | NanoBot | **PicoClaw** |
-| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
-| **Linguagem** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB*** |
-| **Inicialização**(CPU 0.8GHz) | >500s | >30s | **<1s** |
-| **Custo** | Mac Mini $599 | Maioria dos SBC Linux ~$50 | **Qualquer placa Linux****A partir de $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **Linguagem** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Tempo de boot**(core 0,8GHz) | >500s | >30s | **<1s** |
+| **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux****a partir de $10** |
-> 📋 **[Lista de Compatibilidade de Hardware](docs/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 a Raspberry Pi e telefones Android. Sua placa não está listada? Envie um PR!
+
+
+> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
+
+
+
+
## 🦾 Demonstração
### 🛠️ Fluxos de Trabalho Padrão do Assistente
-
- 🧩 Engenharia Full-Stack
- 🗂️ Gerenciamento de Logs & Planejamento
- 🔎 Busca Web & Aprendizado
-
-
-
-
-
-
-
- Desenvolver • Implantar • Escalar
- Agendar • Automatizar • Memorizar
- Descobrir • Analisar • Tendências
-
+
+Modo Engenheiro Full-Stack
+Registro e Planejamento
+Busca na Web e Aprendizado
+
+
+
+
+
+
+
+Desenvolver · Implantar · Escalar
+Agendar · Automatizar · Lembrar
+Descobrir · Insights · Tendências
+
-### 📱 Rode em celulares Android antigos
-
-Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:
-
-1. **Instale o [Termux](https://github.com/termux/termux-app)** (Baixe em [GitHub Releases](https://github.com/termux/termux-app/releases), ou busque no F-Droid / Google Play).
-2. **Execute os comandos**
-
-```bash
-# Baixe a versão mais recente em 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 # chroot fornece um layout padrão do sistema de arquivos Linux
-```
-
-Depois siga as instruções na seção "Início Rápido" para completar a configuração!
-
-
-
-### 🐜 Implantação Inovadora com Baixo Consumo
+### 🐜 Implantação Inovadora de Baixo Consumo
O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
-- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E(Ethernet) ou W(WiFi6), para Assistente Doméstico Minimalista
-- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores
-- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
+- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) edição E(Ethernet) ou W(WiFi6), para um assistente doméstico mínimo
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), para operações automatizadas de servidor
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), para vigilância inteligente
-🌟 Mais cenários de implantação aguardam você!
+🌟 Mais Casos de Implantação Aguardam!
## 📦 Instalação
-### Baixar de picoclaw.io (Recomendado)
+### Download pelo picoclaw.io (Recomendado)
-Visite **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e oferece download com um clique. Sem necessidade de escolher manualmente a arquitetura.
+Acesse **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e fornece download com um clique. Não é necessário selecionar a arquitetura manualmente.
-### Baixar binário pré-compilado
+### Download do binário pré-compilado
Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
@@ -178,80 +166,413 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build, sem necessidade de instalar
+# Compilar o binário principal
make build
-# Build para múltiplas plataformas
+# Compilar o Web UI Launcher (necessário para o modo WebUI)
+make build-launcher
+
+# Compilar para múltiplas plataformas
make build-all
-# Build para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+# Compilar para Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
-# Build e Instalar
+# Compilar e instalar
make install
```
-**Raspberry Pi Zero 2 W:** Use o binário correspondente ao seu SO: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.
+**Raspberry Pi Zero 2 W:** Use o binário que corresponde ao seu SO: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Ou execute `make build-pi-zero` para compilar ambos.
-## 📚 Documentação
+## 🚀 Guia de Início Rápido
-Para guias detalhados, consulte a documentação abaixo. Este README cobre apenas o início rápido.
+### 🌐 WebUI Launcher (Recomendado para Desktop)
-| Tópico | Descrição |
-|--------|-----------|
-| 🐳 [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração Docker Compose, modos Launcher/Agent, configuração de Início Rápido |
-| 💬 [Apps de Chat](docs/pt-br/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom e mais |
-| ⚙️ [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, estrutura do workspace, fontes de skills, sandbox de segurança, heartbeat |
-| 🔌 [Provedores & Modelos](docs/pt-br/providers.md) | 20+ provedores LLM, roteamento de modelos, configuração model_list, arquitetura de provedores |
-| 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes |
-| 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
-| 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução |
-| 📋 [Compatibilidade de Hardware](docs/hardware-compatibility.md) | Placas testadas, requisitos mínimos, como adicionar sua placa |
+O WebUI Launcher fornece uma interface baseada em navegador para configuração e chat. Esta é a maneira mais fácil de começar — sem necessidade de conhecimento de linha de comando.
-## Junte-se à Rede Social de Agentes
+**Opção 1: Duplo clique (Desktop)**
-Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
+Após baixar de [picoclaw.io](https://picoclaw.io), dê duplo clique em `picoclaw-launcher` (ou `picoclaw-launcher.exe` no Windows). Seu navegador abrirá automaticamente em `http://localhost:18800`.
+
+**Opção 2: Linha de comando**
+
+```bash
+picoclaw-launcher
+# Abra http://localhost:18800 no seu navegador
+```
+
+> [!TIP]
+> **Acesso remoto / Docker / VM:** Adicione a flag `-public` para escutar em todas as interfaces:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**Primeiros passos:**
+
+Abra o WebUI e então: **1)** Configure um Provider (adicione sua API key de LLM) -> **2)** Configure um Channel (ex.: Telegram) -> **3)** Inicie o Gateway -> **4)** Converse!
+
+Para documentação detalhada do WebUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
+
+
+Docker (alternativa)
+
+```bash
+# 1. Clone este repositório
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Primeira execução — gera automaticamente docker/data/config.json e encerra
+# (só é acionado quando config.json e workspace/ estão ausentes)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# O container imprime "First-run setup complete." e para.
+
+# 3. Configure suas API keys
+vim docker/data/config.json
+
+# 4. Iniciar
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# Abra http://localhost:18800
+```
+
+> **Usuários de Docker / VM:** O Gateway escuta em `127.0.0.1` por padrão. Defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` ou use a flag `-public` para torná-lo acessível pelo host.
+
+```bash
+# Verificar logs
+docker compose -f docker/docker-compose.yml logs -f
+
+# Parar
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# Atualizar
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher (Recomendado para Headless / SSH)
+
+O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**Primeiros passos:**
+
+Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
+
+Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
+
+### 📱 Android
+
+Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
+
+**Opção 1: Termux (disponível agora)**
+
+1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
+2. Execute os seguintes comandos:
+
+```bash
+# Baixar a versão mais recente
+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 # chroot fornece um layout padrão de sistema de arquivos Linux
+```
+
+Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração.
+
+
+
+**Opção 2: Instalação via APK (em breve)**
+
+Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
+
+
+Terminal Launcher (para ambientes com recursos limitados)
+
+Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
+
+**1. Inicializar**
+
+```bash
+picoclaw onboard
+```
+
+Isso cria `~/.picoclaw/config.json` e o diretório workspace.
+
+**2. Configurar** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> Veja `config/config.example.json` no repositório para um template de configuração completo com todas as opções disponíveis.
+
+**3. Conversar**
+
+```bash
+# Pergunta única
+picoclaw agent -m "What is 2+2?"
+
+# Modo interativo
+picoclaw agent
+
+# Iniciar gateway para integração com app de chat
+picoclaw gateway
+```
+
+
+
+## 🔌 Providers (LLM)
+
+O PicoClaw suporta mais de 30 providers de LLM através da configuração `model_list`. Use o formato `protocolo/modelo`:
+
+| Provider | Protocolo | API Key | Notas |
+|----------|-----------|---------|-------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Obrigatória | GPT-5.4, GPT-4o, o3, etc. |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Obrigatória | Claude Opus 4.6, Sonnet 4.6, etc. |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Obrigatória | Gemini 3 Flash, 2.5 Pro, etc. |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Obrigatória | 200+ modelos, API unificada |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Obrigatória | GLM-4.7, GLM-5, etc. |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Obrigatória | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | Obrigatória | Modelos Doubao, Ark |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Obrigatória | Qwen3, Qwen-Max, etc. |
+| [Groq](https://console.groq.com/keys) | `groq/` | Obrigatória | Inferência rápida (Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Obrigatória | Modelos Kimi |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Obrigatória | Modelos MiniMax |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Obrigatória | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Obrigatória | Modelos hospedados pela NVIDIA |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Obrigatória | Inferência rápida |
+| [Novita AI](https://novita.ai/) | `novita/` | Obrigatória | Vários modelos abertos |
+| [Ollama](https://ollama.com/) | `ollama/` | Não necessária | Modelos locais, self-hosted |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | Não necessária | Implantação local, compatível com OpenAI |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Varia | Proxy para 100+ providers |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Obrigatória | Implantação Azure Enterprise |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login por código de dispositivo |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+Implantação local (Ollama, vLLM, etc.)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md).
+
+
+
+## 💬 Channels (Apps de Chat)
+
+Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
+
+| Channel | Configuração | Protocolo | Docs |
+|---------|--------------|-----------|------|
+| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) |
+| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) |
+| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) |
+| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) |
+| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) |
+| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) |
+| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) |
+| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) |
+| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) |
+| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) |
+| **WeCom Bot** | Médio (webhook URL) | Webhook | [Guia](docs/channels/wecom/wecom_bot/README.pt-br.md) |
+| **WeCom App** | Médio (credenciais corporativas) | Webhook | [Guia](docs/channels/wecom/wecom_app/README.pt-br.md) |
+| **WeCom AI Bot** | Médio (token + chave AES) | WebSocket / Webhook | [Guia](docs/channels/wecom/wecom_aibot/README.pt-br.md) |
+| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) |
+| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) |
+| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) |
+| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
+| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
+
+> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
+
+Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
+
+## 🔧 Ferramentas
+
+### 🔍 Busca na Web
+
+O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Configure em `tools.web`:
+
+| Motor de Busca | API Key | Nível Gratuito | Link |
+|----------------|---------|----------------|------|
+| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
+| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
+| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
+| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
+| [SearXNG](https://github.com/searxng/searxng) | Não necessária | Self-hosted | Metabuscador gratuito |
+| [GLM Search](https://open.bigmodel.cn/) | Obrigatória | Varia | Busca web Zhipu |
+
+### ⚙️ Outras Ferramentas
+
+O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes.
+
+## 🎯 Skills
+
+Skills são capacidades modulares que estendem seu Agent. Elas são carregadas a partir de arquivos `SKILL.md` no seu workspace.
+
+**Instalar skills do ClawHub:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**Configurar token do ClawHub** (opcional, para limites de taxa mais altos):
+
+Adicione ao seu `config.json`:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool).
+
+## 🔗 MCP (Model Context Protocol)
+
+O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — conecte qualquer servidor MCP para estender as capacidades do seu Agent com ferramentas externas e fontes de dados.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool).
+
+## Junte-se à Rede Social de Agents
+
+Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Referência CLI
-| Comando | Descrição |
-| ------------------------- | ----------------------------- |
-| `picoclaw onboard` | Inicializar configuração & workspace |
-| `picoclaw agent -m "..."` | Conversar com o agente |
-| `picoclaw agent` | Modo de chat interativo |
-| `picoclaw gateway` | Iniciar o gateway |
-| `picoclaw status` | Mostrar status |
-| `picoclaw version` | Mostrar informações de versão |
-| `picoclaw cron list` | Listar todas as tarefas agendadas |
-| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
-| `picoclaw cron disable` | Desabilitar uma tarefa agendada |
-| `picoclaw cron remove` | Remover uma tarefa agendada |
-| `picoclaw skills list` | Listar skills instaladas |
-| `picoclaw skills install` | Instalar uma skill |
-| `picoclaw migrate` | Migrar dados de versões anteriores |
-| `picoclaw auth login` | Autenticar com provedores |
-| `picoclaw model` | Ver ou trocar o modelo padrão |
+| Comando | Descrição |
+| ------------------------- | -------------------------------------- |
+| `picoclaw onboard` | Inicializar config e workspace |
+| `picoclaw onboard weixin` | Conectar conta WeChat via QR |
+| `picoclaw agent -m "..."` | Conversar com o agent |
+| `picoclaw agent` | Modo de chat interativo |
+| `picoclaw gateway` | Iniciar o gateway |
+| `picoclaw status` | Exibir status |
+| `picoclaw version` | Exibir informações de versão |
+| `picoclaw model` | Ver ou trocar o modelo padrão |
+| `picoclaw cron list` | Listar todos os jobs agendados |
+| `picoclaw cron add ...` | Adicionar um job agendado |
+| `picoclaw cron disable` | Desabilitar um job agendado |
+| `picoclaw cron remove` | Remover um job agendado |
+| `picoclaw skills list` | Listar skills instaladas |
+| `picoclaw skills install` | Instalar uma skill |
+| `picoclaw migrate` | Migrar dados de versões anteriores |
+| `picoclaw auth login` | Autenticar com providers |
-### Tarefas Agendadas / Lembretes
+### ⏰ Tarefas Agendadas / Lembretes
-O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
+O PicoClaw suporta lembretes agendados e tarefas recorrentes através da ferramenta `cron`:
-* **Lembretes únicos**: "Me lembre em 10 minutos" → dispara uma vez após 10min
-* **Tarefas recorrentes**: "Me lembre a cada 2 horas" → dispara a cada 2 horas
-* **Expressões Cron**: "Me lembre às 9h todos os dias" → usa expressão cron
+* **Lembretes únicos**: "Lembre-me em 10 minutos" -> dispara uma vez após 10min
+* **Tarefas recorrentes**: "Lembre-me a cada 2 horas" -> dispara a cada 2 horas
+* **Expressões cron**: "Lembre-me às 9h diariamente" -> usa expressão cron
+
+## 📚 Documentação
+
+Para guias detalhados além deste README:
+
+| Tópico | Descrição |
+|--------|-----------|
+| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent |
+| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels |
+| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança |
+| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list |
+| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents |
+| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação |
+| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução |
+| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
+| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
+| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills |
+| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos |
## 🤝 Contribuir & Roadmap
-PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗
+PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível.
-Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) completo.
+Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes.
-Grupo de desenvolvedores em formação. Junte-se após seu primeiro PR com merge!
+Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado!
-Grupos de usuários:
+Grupos de Usuários:
-discord:
+Discord:
-
+WeChat:
+
diff --git a/README.vi.md b/README.vi.md
index cd65ac526..b63fd4ef7 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -1,9 +1,9 @@
-
+
-
PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go
+
PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go
-
Phần cứng $10 · <10MB RAM · Khởi động <1 giây · Nào, xuất phát!
+
Phần cứng $10 · RAM 10MB · Khởi động ms · Let's Go, PicoClaw!
@@ -24,153 +24,141 @@
---
-> **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
+> **PicoClaw** là một dự án mã nguồn mở độc lập do [Sipeed](https://sipeed.com) khởi xướng, được viết hoàn toàn bằng **Go** từ đầu — không phải fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
-🦐 PicoClaw là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng Go thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
+**PicoClaw** là trợ lý AI cá nhân siêu nhẹ lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot). Nó được xây dựng lại từ đầu bằng **Go** thông qua quá trình "tự khởi động" — chính AI Agent đã dẫn dắt quá trình di chuyển kiến trúc và tối ưu hóa mã nguồn.
-⚡️ Chạy trên phần cứng chỉ $10 với RAM <10MB: Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
+**Chạy trên phần cứng $10 với <10MB RAM** — ít hơn 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
> [!CAUTION]
-> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
+> **Thông báo Bảo mật**
>
-> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
->
-> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**
-> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký.
-> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
-> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
+> * **KHÔNG CÓ CRYPTO:** PicoClaw **chưa** phát hành bất kỳ token hay tiền điện tử chính thức nào. Mọi thông tin trên `pump.fun` hoặc các nền tảng giao dịch khác đều là **lừa đảo**.
+> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, và website công ty là **[sipeed.com](https://sipeed.com)**
+> * **CẢNH BÁO:** Nhiều domain `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký. Đừng tin tưởng chúng.
+> * **LƯU Ý:** PicoClaw đang trong giai đoạn phát triển nhanh. Có thể còn các vấn đề bảo mật chưa được giải quyết. Không triển khai lên môi trường production trước v1.0.
+> * **LƯU Ý:** PicoClaw gần đây đã merge nhiều PR. Các bản build gần đây có thể dùng 10-20MB RAM. Tối ưu hóa tài nguyên được lên kế hoạch sau khi tính năng ổn định.
## 📢 Tin tức
-2026-03-17 🚀 **v0.2.3 Phát hành!** Giao diện khay hệ thống (Windows & Linux), theo dõi trạng thái sub-agent (`spawn_status`), hot-reload gateway thử nghiệm, cổng bảo mật cron và 2 bản vá bảo mật. PicoClaw đạt **25K ⭐**!
+2026-03-17 🚀 **v0.2.3 đã phát hành!** Giao diện system tray (Windows & Linux), truy vấn trạng thái sub-agent (`spawn_status`), thử nghiệm Gateway hot-reload, bảo mật Cron, và 2 bản vá bảo mật. PicoClaw đã đạt **25K Stars**!
-2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất!** Hỗ trợ giao thức MCP, 4 kênh mới (Matrix/IRC/WeCom/Discord Proxy), 3 nhà cung cấp mới (Kimi/Minimax/Avian), pipeline xử lý hình ảnh, bộ nhớ JSONL và định tuyến mô hình.
+2026-03-09 🎉 **v0.2.1 — Bản cập nhật lớn nhất từ trước đến nay!** Hỗ trợ giao thức MCP, 4 Channel mới (Matrix/IRC/WeCom/Discord Proxy), 3 Provider mới (Kimi/Minimax/Avian), pipeline thị giác, bộ nhớ JSONL, định tuyến mô hình.
-2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và launcher Web UI.
+2026-02-28 📦 **v0.2.0** phát hành với hỗ trợ Docker Compose và Web UI Launcher.
-2026-02-26 🎉 PicoClaw đạt **20K stars** chỉ trong 17 ngày! Tự động điều phối kênh và giao diện năng lực đã được triển khai.
+2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động.
-Tin tức cũ hơn...
+Tin tức trước đó...
-2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Vai trò maintainer cộng đồng và [roadmap](ROADMAP.md) đã được công bố chính thức.
+2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt.
-2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Lộ trình dự án và Nhóm phát triển đang được thiết lập.
+2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng.
-2026-02-09 🎉 **PicoClaw chính thức ra mắt!** Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
+2026-02-09 🎉 **PicoClaw ra mắt!** Được xây dựng trong 1 ngày để đưa AI Agent lên phần cứng $10 với <10MB RAM. Let's Go, PicoClaw!
-## ✨ Tính năng nổi bật
+## ✨ Tính năng
-🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với OpenClaw (chức năng cốt lõi).*
+🪶 **Siêu nhẹ**: Bộ nhớ lõi <10MB — nhỏ hơn 99% so với OpenClaw.*
💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
-⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong <1 giây ngay cả trên CPU đơn nhân 0.6GHz.
+⚡️ **Khởi động cực nhanh**: Khởi động nhanh hơn 400 lần. Khởi động trong <1 giây ngay cả trên bộ xử lý đơn nhân 0.6GHz.
-🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
+🌍 **Thực sự di động**: Một binary duy nhất cho các kiến trúc RISC-V, ARM, MIPS và x86. Một binary, chạy mọi nơi!
-🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
+🤖 **Được AI khởi động**: Triển khai Go thuần túy — 95% mã lõi được tạo bởi Agent và tinh chỉnh qua quy trình human-in-the-loop.
-🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ máy chủ MCP nào để mở rộng khả năng của agent.
+🔌 **Hỗ trợ MCP**: Tích hợp [Model Context Protocol](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent.
-👁️ **Pipeline Xử lý Hình ảnh**: Gửi hình ảnh và tệp trực tiếp cho agent — tự động mã hóa base64 cho các LLM đa phương thức.
+👁️ **Pipeline thị giác**: Gửi hình ảnh và tệp trực tiếp đến Agent — tự động mã hóa base64 cho LLM đa phương thức.
-🧠 **Định tuyến Thông minh**: Định tuyến mô hình dựa trên quy tắc — truy vấn đơn giản chuyển đến mô hình nhẹ, tiết kiệm chi phí API.
+🧠 **Định tuyến thông minh**: Định tuyến mô hình dựa trên quy tắc — các truy vấn đơn giản đến mô hình nhẹ, tiết kiệm chi phí API.
-_*Các phiên bản gần đây có thể sử dụng 10–20MB do merge tính năng nhanh chóng. Tối ưu tài nguyên đang được lên kế hoạch. So sánh thời gian khởi động dựa trên benchmark đơn nhân 0.8GHz (xem bảng bên dưới)._
+_*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối ưu hóa tài nguyên đang được lên kế hoạch. So sánh tốc độ khởi động dựa trên benchmark lõi đơn 0.8GHz (xem bảng bên dưới)._
-| | OpenClaw | NanoBot | **PicoClaw** |
-| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
-| **Ngôn ngữ** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB*** |
-| **Thời gian khởi động**(CPU 0.8GHz) | >500s | >30s | **<1s** |
-| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux****Chỉ từ $10** |
+
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **Ngôn ngữ** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB*** |
+| **Thời gian khởi động**(lõi 0.8GHz) | >500s | >30s | **<1s** |
+| **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux****từ $10** |
-> 📋 **[Danh Sách Tương Thích Phần Cứng](docs/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi và điện thoại Android. Board của bạn chưa có? Gửi PR!
+
-## 🦾 Demo
+> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
-### 🛠️ Quy trình trợ lý tiêu chuẩn
+
+
+
+
+## 🦾 Minh họa
+
+### 🛠️ Quy trình Trợ lý Tiêu chuẩn
-
- 🧩 Lập trình Full-Stack
- 🗂️ Quản lý Nhật ký & Kế hoạch
- 🔎 Tìm kiếm Web & Học hỏi
-
-
-
-
-
-
-
- Phát triển • Triển khai • Mở rộng
- Lên lịch • Tự động hóa • Ghi nhớ
- Khám phá • Phân tích • Xu hướng
-
+
+Chế độ Kỹ sư Full-Stack
+Ghi nhật ký & Lập kế hoạch
+Tìm kiếm Web & Học tập
+
+
+
+
+
+
+
+Phát triển · Triển khai · Mở rộng
+Lên lịch · Tự động hóa · Ghi nhớ
+Khám phá · Thông tin · Xu hướng
+
-### 📱 Chạy trên điện thoại Android cũ
+### 🐜 Triển khai Sáng tạo với Dấu chân Nhỏ
-Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thành trợ lý AI thông minh với PicoClaw. Bắt đầu nhanh:
+PicoClaw có thể được triển khai trên hầu hết mọi thiết bị Linux!
-1. **Cài đặt [Termux](https://github.com/termux/termux-app)** (Tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm trên F-Droid / Google Play).
-2. **Chạy các lệnh**
-
-```bash
-# Tải phiên bản mới nhất từ 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 # chroot cung cấp bố cục hệ thống tệp Linux tiêu chuẩn
-```
-
-Sau đó làm theo hướng dẫn trong phần "Bắt đầu nhanh" để hoàn tất cấu hình!
-
-
-
-### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
-
-PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
-
-- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), dùng làm Trợ lý Gia đình tối giản
-- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) dùng cho quản trị Server tự động
-- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) dùng cho Giám sát thông minh
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E(Ethernet) hoặc W(WiFi6), cho trợ lý gia đình tối giản
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), cho vận hành máy chủ tự động
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), cho giám sát thông minh
-🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
+🌟 Còn nhiều trường hợp triển khai đang chờ đón!
## 📦 Cài đặt
-### Tải từ picoclaw.io (Khuyến nghị)
+### Tải xuống từ picoclaw.io (Khuyến nghị)
-Truy cập **[picoclaw.io](https://picoclaw.io)** — trang web chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
+Truy cập **[picoclaw.io](https://picoclaw.io)** — website chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
-### Tải binary đã biên dịch sẵn
+### Tải xuống binary đã biên dịch sẵn
-Hoặc tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+Ngoài ra, tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
-### Biên dịch từ mã nguồn (cho phát triển)
+### Xây dựng từ mã nguồn (để phát triển)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -178,80 +166,413 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build (không cần cài đặt)
+# Build core binary
make build
-# Build cho nhiều nền tảng
+# Build Web UI Launcher (required for WebUI mode)
+make build-launcher
+
+# Build for multiple platforms
make build-all
-# Build cho Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
-# Build và cài đặt
+# Build and install
make install
```
-**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành: Raspberry Pi OS 32-bit → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để build cả hai.
+**Raspberry Pi Zero 2 W:** Sử dụng binary phù hợp với hệ điều hành của bạn: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Hoặc chạy `make build-pi-zero` để xây dựng cả hai.
-## 📚 Tài liệu
+## 🚀 Hướng dẫn Khởi động Nhanh
-Để xem hướng dẫn chi tiết, tham khảo tài liệu bên dưới. README này chỉ bao gồm phần bắt đầu nhanh.
+### 🌐 WebUI Launcher (Khuyến nghị cho Desktop)
-| Chủ đề | Mô tả |
-|--------|-------|
-| 🐳 [Docker & Bắt đầu nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent, cấu hình Bắt đầu nhanh |
-| 💬 [Ứng dụng Chat](docs/vi/chat-apps.md) | Telegram, Discord, WhatsApp, Matrix, QQ, Slack, IRC, DingTalk, LINE, Feishu, WeCom và nhiều hơn |
-| ⚙️ [Cấu hình](docs/vi/configuration.md) | Biến môi trường, cấu trúc workspace, nguồn skill, sandbox bảo mật, heartbeat |
-| 🔌 [Nhà cung cấp & Mô hình](docs/vi/providers.md) | 20+ nhà cung cấp LLM, định tuyến mô hình, cấu hình model_list, kiến trúc nhà cung cấp |
-| 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
-| 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
-| 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi |
-| 📋 [Tương Thích Phần Cứng](docs/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu, cách thêm board |
+WebUI Launcher cung cấp giao diện dựa trên trình duyệt để cấu hình và trò chuyện. Đây là cách dễ nhất để bắt đầu — không cần kiến thức dòng lệnh.
+
+**Tùy chọn 1: Nhấp đúp (Desktop)**
+
+Sau khi tải xuống từ [picoclaw.io](https://picoclaw.io), nhấp đúp vào `picoclaw-launcher` (hoặc `picoclaw-launcher.exe` trên Windows). Trình duyệt của bạn sẽ tự động mở tại `http://localhost:18800`.
+
+**Tùy chọn 2: Dòng lệnh**
+
+```bash
+picoclaw-launcher
+# Mở http://localhost:18800 trong trình duyệt của bạn
+```
+
+> [!TIP]
+> **Truy cập từ xa / Docker / VM:** Thêm cờ `-public` để lắng nghe trên tất cả giao diện:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**Bắt đầu:**
+
+Mở WebUI, sau đó: **1)** Cấu hình Provider (thêm API key LLM của bạn) -> **2)** Cấu hình Channel (ví dụ: Telegram) -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
+
+Để biết tài liệu WebUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
+
+
+Docker (thay thế)
+
+```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
+# (only triggers when both config.json and workspace/ are missing)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# The container prints "First-run setup complete." and stops.
+
+# 3. Set your API keys
+vim docker/data/config.json
+
+# 4. Start
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# Open http://localhost:18800
+```
+
+> **Người dùng Docker / VM:** Gateway lắng nghe trên `127.0.0.1` theo mặc định. Đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` hoặc dùng cờ `-public` để có thể truy cập từ host.
+
+```bash
+# Check logs
+docker compose -f docker/docker-compose.yml logs -f
+
+# Stop
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# Update
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
+
+TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**Bắt đầu:**
+
+Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
+
+Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
+
+### 📱 Android
+
+Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
+
+**Tùy chọn 1: Termux (có sẵn ngay)**
+
+1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
+2. Chạy các lệnh sau:
+
+```bash
+# Download the latest release
+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 # chroot provides a standard Linux filesystem layout
+```
+
+Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình.
+
+
+
+**Tùy chọn 2: Cài đặt APK (sắp ra mắt)**
+
+Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
+
+
+Terminal Launcher (cho môi trường hạn chế tài nguyên)
+
+Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
+
+**1. Khởi tạo**
+
+```bash
+picoclaw onboard
+```
+
+Lệnh này tạo `~/.picoclaw/config.json` và thư mục workspace.
+
+**2. Cấu hình** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> Xem `config/config.example.json` trong repo để có mẫu cấu hình đầy đủ với tất cả các tùy chọn có sẵn.
+
+**3. Trò chuyện**
+
+```bash
+# One-shot question
+picoclaw agent -m "What is 2+2?"
+
+# Interactive mode
+picoclaw agent
+
+# Start gateway for chat app integration
+picoclaw gateway
+```
+
+
+
+## 🔌 Providers (LLM)
+
+PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử dụng định dạng `protocol/model`:
+
+| Provider | Protocol | API Key | Ghi chú |
+|----------|----------|---------|---------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Bắt buộc | GPT-5.4, GPT-4o, o3, v.v. |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Bắt buộc | Claude Opus 4.6, Sonnet 4.6, v.v. |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Bắt buộc | Gemini 3 Flash, 2.5 Pro, v.v. |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Bắt buộc | 200+ mô hình, API thống nhất |
+| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Bắt buộc | GLM-4.7, GLM-5, v.v. |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Bắt buộc | DeepSeek-V3, DeepSeek-R1 |
+| [Volcengine](https://console.volcengine.com) | `volcengine/` | Bắt buộc | Doubao, Ark models |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Bắt buộc | Qwen3, Qwen-Max, v.v. |
+| [Groq](https://console.groq.com/keys) | `groq/` | Bắt buộc | Suy luận nhanh (Llama, Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Bắt buộc | Kimi models |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Bắt buộc | MiniMax models |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Bắt buộc | Mistral Large, Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Bắt buộc | Mô hình do NVIDIA lưu trữ |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Bắt buộc | Suy luận nhanh |
+| [Novita AI](https://novita.ai/) | `novita/` | Bắt buộc | Nhiều mô hình mở |
+| [Ollama](https://ollama.com/) | `ollama/` | Không cần | Mô hình cục bộ, tự lưu trữ |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | Không cần | Triển khai cục bộ, tương thích OpenAI |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Tùy | Proxy cho 100+ provider |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Bắt buộc | Triển khai Azure doanh nghiệp |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Đăng nhập bằng device code |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+Triển khai cục bộ (Ollama, vLLM, v.v.)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md).
+
+
+
+## 💬 Channels (Ứng dụng Chat)
+
+Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
+
+| Channel | Thiết lập | Protocol | Tài liệu |
+|---------|-----------|----------|----------|
+| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) |
+| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) |
+| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) |
+| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) |
+| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) |
+| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) |
+| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) |
+| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) |
+| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) |
+| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) |
+| **WeCom Bot** | Trung bình (webhook URL) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_bot/README.vi.md) |
+| **WeCom App** | Trung bình (corp credentials) | Webhook | [Hướng dẫn](docs/channels/wecom/wecom_app/README.vi.md) |
+| **WeCom AI Bot** | Trung bình (token + AES key) | WebSocket / Webhook | [Hướng dẫn](docs/channels/wecom/wecom_aibot/README.vi.md) |
+| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) |
+| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) |
+| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) |
+| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
+| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
+
+> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
+
+Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
+
+## 🔧 Tools
+
+### 🔍 Tìm kiếm Web
+
+PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. Cấu hình trong `tools.web`:
+
+| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
+|------------------|---------|--------------|----------|
+| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
+| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
+| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
+| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
+| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
+| [SearXNG](https://github.com/searxng/searxng) | Không cần | Tự lưu trữ | Metasearch engine miễn phí |
+| [GLM Search](https://open.bigmodel.cn/) | Bắt buộc | Tùy | Tìm kiếm web Zhipu |
+
+### ⚙️ Các Tools Khác
+
+PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết.
+
+## 🎯 Skills
+
+Skills là các khả năng mô-đun mở rộng Agent của bạn. Chúng được tải từ các tệp `SKILL.md` trong workspace của bạn.
+
+**Cài đặt Skills từ ClawHub:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**Cấu hình token ClawHub** (tùy chọn, để có giới hạn tốc độ cao hơn):
+
+Thêm vào `config.json` của bạn:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool).
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nối bất kỳ MCP server nào để mở rộng khả năng Agent của bạn với các tool và nguồn dữ liệu bên ngoài.
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool).
## Tham gia Mạng xã hội Agent
-Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
+Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp.
**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Tham chiếu CLI
-| Lệnh | Mô tả |
-| -------------------------- | ------------------------------ |
-| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
-| `picoclaw agent -m "..."` | Trò chuyện với agent |
-| `picoclaw agent` | Chế độ chat tương tác |
-| `picoclaw gateway` | Khởi động gateway |
-| `picoclaw status` | Hiển thị trạng thái |
-| `picoclaw version` | Hiển thị thông tin phiên bản |
-| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
-| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
-| `picoclaw cron disable` | Tắt tác vụ định kỳ |
-| `picoclaw cron remove` | Xóa tác vụ định kỳ |
-| `picoclaw skills list` | Liệt kê các skill đã cài |
-| `picoclaw skills install` | Cài đặt một skill |
-| `picoclaw migrate` | Di chuyển dữ liệu từ phiên bản cũ |
-| `picoclaw auth login` | Xác thực với nhà cung cấp |
-| `picoclaw model` | Xem hoặc chuyển đổi model mặc định |
+| Lệnh | Mô tả |
+| ------------------------- | ---------------------------------------- |
+| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
+| `picoclaw onboard weixin` | Kết nối tài khoản WeChat qua QR |
+| `picoclaw agent -m "..."` | Trò chuyện với agent |
+| `picoclaw agent` | Chế độ trò chuyện tương tác |
+| `picoclaw gateway` | Khởi động gateway |
+| `picoclaw status` | Hiển thị trạng thái |
+| `picoclaw version` | Hiển thị thông tin phiên bản |
+| `picoclaw model` | Xem hoặc chuyển đổi mô hình mặc định |
+| `picoclaw cron list` | Liệt kê tất cả công việc đã lên lịch |
+| `picoclaw cron add ...` | Thêm công việc đã lên lịch |
+| `picoclaw cron disable` | Vô hiệu hóa công việc đã lên lịch |
+| `picoclaw cron remove` | Xóa công việc đã lên lịch |
+| `picoclaw skills list` | Liệt kê các Skill đã cài đặt |
+| `picoclaw skills install` | Cài đặt một Skill |
+| `picoclaw migrate` | Di chuyển dữ liệu từ các phiên bản cũ |
+| `picoclaw auth login` | Xác thực với các provider |
-### Tác vụ định kỳ / Nhắc nhở
+### ⏰ Tác vụ Đã lên lịch / Nhắc nhở
-PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
+PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ thông qua tool `cron`:
-* **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" → kích hoạt một lần sau 10 phút
-* **Tác vụ lặp lại**: "Nhắc tôi mỗi 2 giờ" → kích hoạt mỗi 2 giờ
-* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng mỗi ngày" → sử dụng biểu thức cron
+* **Nhắc nhở một lần**: "Nhắc tôi sau 10 phút" -> kích hoạt một lần sau 10 phút
+* **Tác vụ định kỳ**: "Nhắc tôi mỗi 2 giờ" -> kích hoạt mỗi 2 giờ
+* **Biểu thức Cron**: "Nhắc tôi lúc 9 giờ sáng hàng ngày" -> sử dụng biểu thức cron
+
+## 📚 Tài liệu
+
+Để biết các hướng dẫn chi tiết ngoài README này:
+
+| Chủ đề | Mô tả |
+|--------|-------|
+| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent |
+| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel |
+| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật |
+| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list |
+| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
+| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook |
+| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
+| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
+| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
+| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills |
+| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu |
## 🤝 Đóng góp & Lộ trình
-Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
+PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc.
-Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md) đầy đủ.
+Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn.
-Nhóm phát triển đang được xây dựng. Tham gia sau khi có PR đầu tiên được merge!
+Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge!
-Nhóm người dùng:
+Nhóm Người dùng:
-discord:
+Discord:
-
+WeChat:
+
diff --git a/README.zh.md b/README.zh.md
index 1bc5d1a4b..de96e5164 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -3,7 +3,7 @@
PicoClaw: 基于Go语言的超高效 AI 助手
-$10 硬件 · <10MB 内存 · <1s 启动 · 皮皮虾,我们走!
+$10 硬件 · 10MB 内存 · 毫秒启动 · 皮皮虾,我们走!
@@ -95,6 +95,8 @@
_*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入计划。启动速度对比基于 0.8GHz 单核实测(见下方对比表)。_
+
+
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **语言** | TypeScript | Python | **Go** |
@@ -104,7 +106,13 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
-> 📋 **[硬件兼容列表](docs/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
+
+
+> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
+
+
+
+
## 🦾 演示
@@ -128,25 +136,6 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
-### 📱 在手机上轻松运行
-
-PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的 AI 助理!快速指南:
-
-1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid 等应用商店搜索)
-2. 打开后执行指令
-
-```bash
-# 从 Release 页面下载最新版本
-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 # chroot 提供标准 Linux 文件系统布局
-```
-
-然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用!
-
-
-
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
@@ -177,9 +166,12 @@ git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# 构建(无需安装)
+# 构建核心二进制文件
make build
+# 构建 Web UI Launcher(WebUI 模式必需)
+make build-launcher
+
# 为多平台构建
make build-all
@@ -192,20 +184,330 @@ make install
**Raspberry Pi Zero 2 W:** 请使用与系统匹配的二进制文件:32 位 Raspberry Pi OS → `make build-linux-arm`;64 位 → `make build-linux-arm64`。或运行 `make build-pi-zero` 同时构建两者。
-## 📚 文档
+## 🚀 快速开始
-详细指南请参阅以下文档,README 仅涵盖快速入门。
+### 🌐 WebUI Launcher(推荐桌面用户)
-| 主题 | 说明 |
-|------|------|
-| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
-| 💬 [聊天应用配置](docs/zh/chat-apps.md) | Telegram、Discord、WhatsApp、Matrix、QQ、Slack、IRC、钉钉、LINE、飞书、企业微信等 |
-| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、技能来源、安全沙箱、心跳任务 |
-| 🔌 [提供商与模型配置](docs/zh/providers.md) | 20+ LLM 提供商、模型路由、model_list 配置、Provider 架构 |
-| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
-| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
-| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 |
-| 📋 [硬件兼容列表](docs/hardware-compatibility.md) | 已测试板卡、最低要求、如何添加你的板卡 |
+WebUI Launcher 提供基于浏览器的配置与聊天界面,是最简单的上手方式——无需命令行知识。
+
+**方式一:双击启动(桌面)**
+
+从 [picoclaw.io](https://picoclaw.io) 下载后,双击 `picoclaw-launcher`(Windows 上为 `picoclaw-launcher.exe`),浏览器将自动打开 `http://localhost:18800`。
+
+**方式二:命令行**
+
+```bash
+picoclaw-launcher
+# 在浏览器中打开 http://localhost:18800
+```
+
+> [!TIP]
+> **远程访问 / Docker / 虚拟机:** 添加 `-public` 参数以监听所有网络接口:
+> ```bash
+> picoclaw-launcher -public
+> ```
+
+
+
+
+
+**开始使用:**
+
+打开 WebUI,然后:**1)** 配置 Provider(填入 LLM API Key)-> **2)** 配置 Channel(如 Telegram)-> **3)** 启动 Gateway -> **4)** 开始聊天!
+
+详细 WebUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
+
+
+Docker(备选方案)
+
+```bash
+# 1. 克隆本仓库
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. 首次运行——自动生成 docker/data/config.json 后退出
+# (仅在 config.json 和 workspace/ 均不存在时触发)
+docker compose -f docker/docker-compose.yml --profile launcher up
+# 容器打印 "First-run setup complete." 后停止。
+
+# 3. 填写 API Key
+vim docker/data/config.json
+
+# 4. 启动
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+# 打开 http://localhost:18800
+```
+
+> **Docker / 虚拟机用户:** Gateway 默认监听 `127.0.0.1`。设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或使用 `-public` 参数以允许从宿主机访问。
+
+```bash
+# 查看日志
+docker compose -f docker/docker-compose.yml logs -f
+
+# 停止
+docker compose -f docker/docker-compose.yml --profile launcher down
+
+# 更新
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile launcher up -d
+```
+
+
+
+### 💻 TUI Launcher(推荐无头环境 / SSH)
+
+TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
+
+```bash
+picoclaw-launcher-tui
+```
+
+
+
+
+
+**开始使用:**
+
+通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天!
+
+详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
+
+### 📱 Android
+
+让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
+
+**方式一:Termux(现已可用)**
+
+1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
+2. 执行以下命令:
+
+```bash
+# 从 Release 页面下载最新版本
+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 # chroot 提供标准 Linux 文件系统布局
+```
+
+然后跟随下面的"Terminal Launcher"章节继续配置。
+
+
+
+**方式二:APK 安装(即将推出)**
+
+内置 WebUI 的独立 Android APK 正在开发中,敬请期待!
+
+
+Terminal Launcher(适用于资源受限环境)
+
+对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
+
+**1. 初始化**
+
+```bash
+picoclaw onboard
+```
+
+此命令会创建 `~/.picoclaw/config.json` 和工作区目录。
+
+**2. 配置** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-api-key"
+ }
+ ]
+}
+```
+
+> 完整配置模板请参阅仓库中的 `config/config.example.json`。
+
+**3. 开始聊天**
+
+```bash
+# 单次提问
+picoclaw agent -m "What is 2+2?"
+
+# 交互式对话模式
+picoclaw agent
+
+# 启动 Gateway 以接入聊天应用
+picoclaw gateway
+```
+
+
+
+## 🔌 Providers (LLM)
+
+PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模型` 格式:
+
+| Provider | 协议 | API Key | 备注 |
+|----------|------|---------|------|
+| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | 必填 | GPT-5.4、GPT-4o、o3 等 |
+| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | 必填 | Claude Opus 4.6、Sonnet 4.6 等 |
+| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | 必填 | Gemini 3 Flash、2.5 Pro 等 |
+| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | 必填 | 200+ 模型,统一 API |
+| [智谱 (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | 必填 | GLM-4.7、GLM-5 等 |
+| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | 必填 | DeepSeek-V3、DeepSeek-R1 |
+| [火山引擎](https://console.volcengine.com) | `volcengine/` | 必填 | 豆包、Ark 系列模型 |
+| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | 必填 | Qwen3、Qwen-Max 等 |
+| [Groq](https://console.groq.com/keys) | `groq/` | 必填 | 快速推理(Llama、Mixtral) |
+| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | 必填 | Kimi 系列模型 |
+| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | 必填 | MiniMax 系列模型 |
+| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | 必填 | Mistral Large、Codestral |
+| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | 必填 | NVIDIA 托管模型 |
+| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | 必填 | 快速推理 |
+| [Novita AI](https://novita.ai/) | `novita/` | 必填 | 多种开源模型 |
+| [Ollama](https://ollama.com/) | `ollama/` | 无需 | 本地模型,自托管 |
+| [vLLM](https://docs.vllm.ai/) | `vllm/` | 无需 | 本地部署,兼容 OpenAI |
+| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | 视情况 | 100+ Provider 代理 |
+| [Azure OpenAI](https://portal.azure.com/) | `azure/` | 必填 | 企业级 Azure 部署 |
+| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | 设备码登录 |
+| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
+
+
+本地部署(Ollama、vLLM 等)
+
+**Ollama:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-llama",
+ "model": "ollama/llama3.1:8b",
+ "api_base": "http://localhost:11434/v1"
+ }
+ ]
+}
+```
+
+**vLLM:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "local-vllm",
+ "model": "vllm/your-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ]
+}
+```
+
+完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。
+
+
+
+## 💬 Channels(聊天应用)
+
+通过 17+ 消息平台与你的 PicoClaw 对话:
+
+| Channel | 配置难度 | 协议 | 文档 |
+|---------|----------|------|------|
+| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
+| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) |
+| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
+| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
+| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) |
+| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) |
+| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) |
+| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) |
+| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
+| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) |
+| **企业微信机器人** | 中等(webhook URL) | Webhook | [指南](docs/channels/wecom/wecom_bot/README.zh.md) |
+| **企业微信应用** | 中等(corp credentials) | Webhook | [指南](docs/channels/wecom/wecom_app/README.zh.md) |
+| **企业微信 AI 机器人** | 中等(token + AES key) | WebSocket / Webhook | [指南](docs/channels/wecom/wecom_aibot/README.zh.md) |
+| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
+| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
+| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |
+| **Pico** | 简单(启用即可) | 原生协议 | 内置 |
+| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 |
+
+> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
+
+详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
+
+## 🔧 Tools
+
+### 🔍 网络搜索
+
+PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
+
+| 搜索引擎 | API Key | 免费额度 | 链接 |
+|---------|---------|---------|------|
+| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 |
+| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
+| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
+| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
+| [Perplexity](https://www.perplexity.ai) | 必填 | 付费 | AI 驱动搜索(国内访问困难) |
+| [Brave Search](https://brave.com/search/api) | 必填 | 2000 次/月 | 快速且注重隐私(国内访问困难) |
+| [SearXNG](https://github.com/searxng/searxng) | 无需 | 自托管 | 免费元搜索引擎 |
+
+### ⚙️ 其他工具
+
+PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。
+
+## 🎯 Skills
+
+Skills 是扩展 Agent 能力的模块化插件,从工作区的 `SKILL.md` 文件加载。
+
+**从 ClawHub 安装 Skills:**
+
+```bash
+picoclaw skills search "web scraping"
+picoclaw skills install
+```
+
+**配置 ClawHub token**(可选,用于提高速率限制):
+
+在 `config.json` 中添加:
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "auth_token": "your-clawhub-token"
+ }
+ }
+ }
+ }
+}
+```
+
+更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。
+
+## 🔗 MCP (Model Context Protocol)
+
+PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 MCP 服务器,通过外部工具和数据源扩展 Agent 能力。
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "servers": {
+ "filesystem": {
+ "enabled": true,
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ }
+ }
+ }
+ }
+}
+```
+
+完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。
## 加入 Agent 社交网络
@@ -218,23 +520,23 @@ make install
| 命令 | 说明 |
| ------------------------- | ---------------------- |
| `picoclaw onboard` | 初始化配置与工作区 |
-| `picoclaw onboard weixin` | 扫码连接微信个人号 |
+| `picoclaw onboard weixin` | 扫码连接微信个人号 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式对话模式 |
| `picoclaw gateway` | 启动网关 |
| `picoclaw status` | 查看状态 |
| `picoclaw version` | 查看版本信息 |
+| `picoclaw model` | 查看或切换默认模型 |
| `picoclaw cron list` | 列出所有定时任务 |
| `picoclaw cron add ...` | 添加定时任务 |
| `picoclaw cron disable` | 禁用定时任务 |
| `picoclaw cron remove` | 删除定时任务 |
-| `picoclaw skills list` | 列出已安装技能 |
-| `picoclaw skills install` | 安装技能 |
+| `picoclaw skills list` | 列出已安装 Skills |
+| `picoclaw skills install` | 安装 Skill |
| `picoclaw migrate` | 从旧版本迁移数据 |
-| `picoclaw auth login` | 认证提供商 |
-| `picoclaw model` | 查看或切换默认模型 |
+| `picoclaw auth login` | 认证 Provider |
-### 定时任务 / 提醒
+### ⏰ 定时任务 / 提醒
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
@@ -242,11 +544,29 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
* **重复任务**: "每2小时提醒我" → 每2小时触发
* **Cron 表达式**: "每天上午9点提醒我" → 使用 cron 表达式
+## 📚 文档
+
+详细指南请参阅以下文档,README 仅涵盖快速入门。
+
+| 主题 | 说明 |
+|------|------|
+| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 |
+| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 |
+| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 |
+| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 |
+| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
+| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
+| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
+| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
+| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
+| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills |
+| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 |
+
## 🤝 贡献与路线图
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
-查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md)。
+查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
@@ -254,4 +574,10 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
Discord:
-
+WeChat:
+
+
+
+
+
+
diff --git a/assets/hardware-banner.jpg b/assets/hardware-banner.jpg
new file mode 100644
index 000000000..f9a1190b1
Binary files /dev/null and b/assets/hardware-banner.jpg differ
diff --git a/assets/launcher-tui.jpg b/assets/launcher-tui.jpg
new file mode 100644
index 000000000..cf5e8ea4d
Binary files /dev/null and b/assets/launcher-tui.jpg differ
diff --git a/assets/launcher-webui.jpg b/assets/launcher-webui.jpg
new file mode 100644
index 000000000..9e7c699b2
Binary files /dev/null and b/assets/launcher-webui.jpg differ
diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go
index 4bf132685..531cb76aa 100644
--- a/cmd/picoclaw/internal/auth/helpers.go
+++ b/cmd/picoclaw/internal/auth/helpers.go
@@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
appCfg, err := internal.LoadConfig()
if err == nil {
- // Update Providers (legacy format)
- appCfg.Providers.OpenAI.AuthMethod = "oauth"
-
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
@@ -71,7 +68,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it
if !foundOpenAI {
- appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
@@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error {
appCfg, err := internal.LoadConfig()
if err == nil {
- // Update Providers (legacy format, for backward compatibility)
- appCfg.Providers.Antigravity.AuthMethod = "oauth"
-
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
@@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error {
// If no antigravity in ModelList, add it
if !foundAntigravity {
- appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
@@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error {
appCfg, err := internal.LoadConfig()
if err == nil {
- appCfg.Providers.Anthropic.AuthMethod = "oauth"
-
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
@@ -221,7 +213,7 @@ func authLoginAnthropicSetupToken() error {
}
}
if !found {
- appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "oauth",
@@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error {
if err == nil {
switch provider {
case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
@@ -298,7 +289,7 @@ func authLoginPasteToken(provider string) error {
}
}
if !found {
- appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token",
@@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error {
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
case "openai":
- appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
@@ -317,7 +307,7 @@ func authLoginPasteToken(provider string) error {
}
}
if !found {
- appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "token",
@@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error {
}
}
}
- // Clear AuthMethod in Providers (legacy)
- switch provider {
- case "openai":
- appCfg.Providers.OpenAI.AuthMethod = ""
- case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = ""
- case "google-antigravity", "antigravity":
- appCfg.Providers.Antigravity.AuthMethod = ""
- }
config.SaveConfig(internal.GetConfigPath(), appCfg)
}
@@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList {
appCfg.ModelList[i].AuthMethod = ""
}
- // Clear all AuthMethods in Providers (legacy)
- appCfg.Providers.OpenAI.AuthMethod = ""
- appCfg.Providers.Anthropic.AuthMethod = ""
- appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(internal.GetConfigPath(), appCfg)
}
diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go
index 4812f1bee..7fa588c5c 100644
--- a/cmd/picoclaw/internal/gateway/command.go
+++ b/cmd/picoclaw/internal/gateway/command.go
@@ -34,7 +34,7 @@ func NewGatewayCommand() *cobra.Command {
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
- return gateway.Run(debug, internal.GetConfigPath(), allowEmpty)
+ return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
},
}
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
index ae1d58c29..17de88ccb 100644
--- a/cmd/picoclaw/internal/helpers.go
+++ b/cmd/picoclaw/internal/helpers.go
@@ -4,11 +4,12 @@ import (
"os"
"path/filepath"
+ "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
-const Logo = "🦞"
+const Logo = pkg.Logo
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
@@ -17,7 +18,7 @@ func GetPicoclawHome() string {
return home
}
home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw")
+ return filepath.Join(home, pkg.DefaultPicoClawHome)
}
func GetConfigPath() string {
@@ -32,7 +33,7 @@ func LoadConfig() (*config.Config, error) {
if err != nil {
return nil, err
}
- logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
+ logger.SetLevelFromString(cfg.Gateway.LogLevel)
return cfg, nil
}
diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go
index 583751781..953da8886 100644
--- a/cmd/picoclaw/internal/helpers_test.go
+++ b/cmd/picoclaw/internal/helpers_test.go
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/config"
)
func TestGetConfigPath(t *testing.T) {
@@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
}
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
- t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
+ t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
@@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
- t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
+ t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go
index cad106fd5..314259d0f 100644
--- a/cmd/picoclaw/internal/model/command.go
+++ b/cmd/picoclaw/internal/model/command.go
@@ -56,9 +56,6 @@ Note: 'local-model' is a special value for using a local VLLM server
func showCurrentModel(cfg *config.Config) {
defaultModel := cfg.Agents.Defaults.ModelName
- if defaultModel == "" {
- defaultModel = cfg.Agents.Defaults.Model
- }
if defaultModel == "" {
fmt.Println("No default model is currently set.")
@@ -78,16 +75,13 @@ func listAvailableModels(cfg *config.Config) {
}
defaultModel := cfg.Agents.Defaults.ModelName
- if defaultModel == "" {
- defaultModel = cfg.Agents.Defaults.Model
- }
for _, model := range cfg.ModelList {
marker := " "
if model.ModelName == defaultModel {
marker = "> "
}
- if model.APIKey == "" {
+ if model.APIKey() == "" {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
@@ -98,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
- if model.APIKey != "" && model.ModelName == modelName {
+ if model.APIKey() != "" && model.ModelName == modelName {
modelFound = true
break
}
@@ -111,12 +105,8 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
// Update the default model
// Clear old model field and set new model_name
oldModel := cfg.Agents.Defaults.ModelName
- if oldModel == "" {
- oldModel = cfg.Agents.Defaults.Model
- }
cfg.Agents.Defaults.ModelName = modelName
- cfg.Agents.Defaults.Model = "" // Clear deprecated field
// Save config back to file
if err := config.SaveConfig(configPath, cfg); err != nil {
diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go
index 82943e4a6..6cbbf0b55 100644
--- a/cmd/picoclaw/internal/model/command_test.go
+++ b/cmd/picoclaw/internal/model/command_test.go
@@ -58,17 +58,24 @@ func TestNewModelCommand(t *testing.T) {
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
- {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4"},
+ {ModelName: "claude-3", Model: "anthropic/claude-3"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "gpt-4": {
+ APIKeys: []string{"test"},
+ },
+ "claude-3": {
+ APIKeys: []string{"test"},
+ },
+ }})
output := captureStdout(func() {
showCurrentModel(cfg)
@@ -81,17 +88,20 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
- Model: "",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "gpt-4": {
+ APIKeys: []string{"test"},
+ },
+ }})
output := captureStdout(func() {
showCurrentModel(cfg)
@@ -101,26 +111,9 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
assert.Contains(t, output, "Available models in your config:")
}
-func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
- cfg := &config.Config{
- Agents: config.AgentsConfig{
- Defaults: config.AgentDefaults{
- Model: "legacy-model",
- },
- },
- ModelList: []config.ModelConfig{},
- }
-
- output := captureStdout(func() {
- showCurrentModel(cfg)
- })
-
- assert.Contains(t, output, "Current default model: legacy-model")
-}
-
func TestListAvailableModels_Empty(t *testing.T) {
cfg := &config.Config{
- ModelList: []config.ModelConfig{},
+ ModelList: []*config.ModelConfig{},
}
output := captureStdout(func() {
@@ -131,18 +124,25 @@ func TestListAvailableModels_Empty(t *testing.T) {
}
func TestListAvailableModels_WithModels(t *testing.T) {
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
- {ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
- {ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4"},
+ {ModelName: "claude-3", Model: "anthropic/claude-3"},
+ {ModelName: "no-key-model", Model: "openai/test"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "gpt-4": {
+ APIKeys: []string{"test"},
+ },
+ "claude-3": {
+ APIKeys: []string{"test"},
+ },
+ }})
output := captureStdout(func() {
listAvailableModels(cfg)
@@ -157,17 +157,24 @@ func TestListAvailableModels_WithModels(t *testing.T) {
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
- {ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "new-model", Model: "openai/new-model"},
+ {ModelName: "old-model", Model: "openai/old-model"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "new-model": {
+ APIKeys: []string{"test"},
+ },
+ "old-model": {
+ APIKeys: []string{"test"},
+ },
+ }})
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
@@ -180,44 +187,25 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
updatedCfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
- assert.Empty(t, updatedCfg.Agents.Defaults.Model)
-}
-
-func TestSetDefaultModel_LegacyModelField(t *testing.T) {
- initTest(t)
-
- cfg := &config.Config{
- Agents: config.AgentsConfig{
- Defaults: config.AgentDefaults{
- Model: "legacy-old",
- },
- },
- ModelList: []config.ModelConfig{
- {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
- },
- }
-
- output := captureStdout(func() {
- err := setDefaultModel(configPath, cfg, "new-model")
- assert.NoError(t, err)
- })
-
- assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
}
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "existing-model", Model: "openai/existing"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "existing-model": {
+ APIKeys: []string{"test"},
+ },
+ }})
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
@@ -225,17 +213,24 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
- {ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "existing-model", Model: "openai/existing"},
+ {ModelName: "no-key-model", Model: "openai/nokey"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "existing-model": {
+ APIKeys: []string{"test"},
+ },
+ "no-key-model": {
+ APIKeys: []string{""},
+ },
+ }})
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
@@ -244,16 +239,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "new-model", Model: "openai/new-model"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "new-model": {
+ APIKeys: []string{"test"},
+ },
+ }})
err := setDefaultModel(invalidPath, cfg, "new-model")
@@ -285,16 +284,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "test-model", Model: "openai/test", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "test-model", Model: "openai/test"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "test-model": {
+ APIKeys: []string{"test"},
+ },
+ }})
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
@@ -312,17 +315,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
- cfg := &config.Config{
+ sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "old-model": {
+ APIKeys: []string{"test"},
+ },
+ "new-model": {
+ APIKeys: []string{"test"},
+ },
+ }}
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "old-model", Model: "openai/old", APIKey: "test"},
- {ModelName: "new-model", Model: "openai/new", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "old-model", Model: "openai/old"},
+ {ModelName: "new-model", Model: "openai/new"},
},
- }
+ }).WithSecurity(sec)
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
@@ -346,18 +357,28 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
- cfg := &config.Config{
+ cfg := (&config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
- ModelList: []config.ModelConfig{
- {ModelName: "first-model", Model: "openai/first", APIKey: "test"},
- {ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
- {ModelName: "last-model", Model: "openai/last", APIKey: "test"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "first-model", Model: "openai/first"},
+ {ModelName: "middle-model", Model: "openai/middle"},
+ {ModelName: "last-model", Model: "openai/last"},
},
- }
+ }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "first-model": {
+ APIKeys: []string{"test"},
+ },
+ "middle-model": {
+ APIKeys: []string{"test"},
+ },
+ "last-model": {
+ APIKeys: []string{"test"},
+ },
+ }})
output := captureStdout(func() {
listAvailableModels(cfg)
diff --git a/cmd/picoclaw/internal/onboard/weixin.go b/cmd/picoclaw/internal/onboard/weixin.go
index 721b4f0e9..2e1c2ad75 100644
--- a/cmd/picoclaw/internal/onboard/weixin.go
+++ b/cmd/picoclaw/internal/onboard/weixin.go
@@ -96,7 +96,7 @@ func saveWeixinConfig(token, baseURL, proxy string) error {
}
cfg.Channels.Weixin.Enabled = true
- cfg.Channels.Weixin.Token = token
+ cfg.Channels.Weixin.SetToken(token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
cfg.Channels.Weixin.BaseURL = baseURL
diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go
index 8c666b810..4f64ef3f9 100644
--- a/cmd/picoclaw/internal/skills/command.go
+++ b/cmd/picoclaw/internal/skills/command.go
@@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
d.workspace = cfg.WorkspacePath()
installer, err := skills.NewSkillInstaller(
d.workspace,
- cfg.Tools.Skills.Github.Token,
+ cfg.Tools.Skills.Github.Token(),
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go
index a59a2013a..a246f7da5 100644
--- a/cmd/picoclaw/internal/skills/helpers.go
+++ b/cmd/picoclaw/internal/skills/helpers.go
@@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
+ clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
- ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ ClawHub: skills.ClawHubConfig{
+ Enabled: clawHubConfig.Enabled,
+ BaseURL: clawHubConfig.BaseURL,
+ AuthToken: clawHubConfig.AuthToken(),
+ SearchPath: clawHubConfig.SearchPath,
+ SkillsPath: clawHubConfig.SkillsPath,
+ DownloadPath: clawHubConfig.DownloadPath,
+ Timeout: clawHubConfig.Timeout,
+ MaxZipSize: clawHubConfig.MaxZipSize,
+ MaxResponseSize: clawHubConfig.MaxResponseSize,
+ },
})
registry := registryMgr.GetRegistry(registryName)
@@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
return
}
+ clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
- ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ ClawHub: skills.ClawHubConfig{
+ Enabled: clawHubConfig.Enabled,
+ BaseURL: clawHubConfig.BaseURL,
+ AuthToken: clawHubConfig.AuthToken(),
+ SearchPath: clawHubConfig.SearchPath,
+ SkillsPath: clawHubConfig.SkillsPath,
+ DownloadPath: clawHubConfig.DownloadPath,
+ Timeout: clawHubConfig.Timeout,
+ MaxZipSize: clawHubConfig.MaxZipSize,
+ MaxResponseSize: clawHubConfig.MaxResponseSize,
+ },
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go
index dd7063fe6..43c5786a8 100644
--- a/cmd/picoclaw/internal/status/helpers.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -42,48 +42,6 @@ func statusCmd() {
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
- hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
- hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
- hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
- hasGemini := cfg.Providers.Gemini.APIKey != ""
- hasZhipu := cfg.Providers.Zhipu.APIKey != ""
- hasQwen := cfg.Providers.Qwen.APIKey != ""
- hasGroq := cfg.Providers.Groq.APIKey != ""
- hasVLLM := cfg.Providers.VLLM.APIBase != ""
- hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
- hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
- hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
- hasNvidia := cfg.Providers.Nvidia.APIKey != ""
- hasOllama := cfg.Providers.Ollama.APIBase != ""
-
- status := func(enabled bool) string {
- if enabled {
- return "✓"
- }
- return "not set"
- }
- fmt.Println("OpenRouter API:", status(hasOpenRouter))
- fmt.Println("Anthropic API:", status(hasAnthropic))
- fmt.Println("OpenAI API:", status(hasOpenAI))
- fmt.Println("Gemini API:", status(hasGemini))
- fmt.Println("Zhipu API:", status(hasZhipu))
- fmt.Println("Qwen API:", status(hasQwen))
- fmt.Println("Groq API:", status(hasGroq))
- fmt.Println("Moonshot API:", status(hasMoonshot))
- fmt.Println("DeepSeek API:", status(hasDeepSeek))
- fmt.Println("VolcEngine API:", status(hasVolcEngine))
- fmt.Println("Nvidia API:", status(hasNvidia))
- if hasVLLM {
- fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
- } else {
- fmt.Println("vLLM/Local: not set")
- }
- if hasOllama {
- fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
- } else {
- fmt.Println("Ollama: not set")
- }
-
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:")
diff --git a/config/config.example.json b/config/config.example.json
index 28b29dfa1..88578701a 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -1,7 +1,6 @@
{
"agents": {
"defaults": {
- "log_level": "fatal",
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt-5.4",
@@ -548,6 +547,7 @@
"monitor_usb": true
},
"voice": {
+ "model_name": "",
"echo_transcription": false
},
"hooks": {
@@ -559,8 +559,10 @@
}
},
"gateway": {
+ "_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"host": "127.0.0.1",
"port": 18790,
- "hot_reload": false
+ "hot_reload": false,
+ "log_level": "fatal"
}
}
diff --git a/docs/channels/matrix/README.fr.md b/docs/channels/matrix/README.fr.md
new file mode 100644
index 000000000..ec762a8b8
--- /dev/null
+++ b/docs/channels/matrix/README.fr.md
@@ -0,0 +1,64 @@
+> Retour au [README](../../../README.fr.md)
+
+# Guide de configuration du canal Matrix
+
+## 1. Exemple de configuration
+
+Ajoutez ceci à `config.json` :
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking..."
+ },
+ "reasoning_channel_id": "",
+ "message_format": "richtext"
+ }
+ }
+}
+```
+
+## 2. Référence des champs
+
+| Champ | Type | Requis | Description |
+|----------------------|----------|--------|-------------|
+| enabled | bool | Oui | Activer ou désactiver le canal Matrix |
+| homeserver | string | Oui | URL du homeserver Matrix (par exemple `https://matrix.org`) |
+| user_id | string | Oui | ID utilisateur Matrix du bot (par exemple `@bot:matrix.org`) |
+| access_token | string | Oui | Jeton d'accès du bot |
+| device_id | string | Non | ID d'appareil Matrix optionnel |
+| join_on_invite | bool | Non | Rejoindre automatiquement les salons invités |
+| allow_from | []string | Non | Liste blanche d'utilisateurs (IDs Matrix) |
+| group_trigger | object | Non | Stratégie de déclenchement de groupe (`mention_only` / `prefixes`) |
+| placeholder | object | Non | Configuration du message de remplacement |
+| reasoning_channel_id | string | Non | Canal cible pour la sortie de raisonnement |
+| message_format | string | Non | Format de sortie : `"richtext"` (défaut) rend le markdown en HTML ; `"plain"` envoie du texte brut uniquement |
+
+## 3. Fonctionnalités actuellement supportées
+
+- Envoi/réception de messages texte avec rendu markdown (gras, italique, titres, blocs de code, etc.)
+- Format de message configurable (`richtext` / `plain`)
+- Téléchargement d'images/audio/vidéo/fichiers entrants (MediaStore en priorité, chemin local en secours)
+- Normalisation de l'audio entrant dans le flux de transcription existant (`[audio: ...]`)
+- Upload et envoi d'images/audio/vidéo/fichiers sortants
+- Règles de déclenchement de groupe (y compris le mode mention uniquement)
+- État de frappe (`m.typing`)
+- Message de remplacement + remplacement de la réponse finale
+- Rejoindre automatiquement les salons invités (peut être désactivé)
+
+## 4. TODO
+
+- Améliorations des métadonnées des médias riches (par exemple taille et miniatures des images/vidéos)
diff --git a/docs/channels/matrix/README.ja.md b/docs/channels/matrix/README.ja.md
new file mode 100644
index 000000000..e5a773d4d
--- /dev/null
+++ b/docs/channels/matrix/README.ja.md
@@ -0,0 +1,64 @@
+> [README](../../../README.ja.md) に戻る
+
+# Matrix チャンネル設定ガイド
+
+## 1. 設定例
+
+`config.json` に以下を追加してください:
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking..."
+ },
+ "reasoning_channel_id": "",
+ "message_format": "richtext"
+ }
+ }
+}
+```
+
+## 2. フィールドリファレンス
+
+| フィールド | 型 | 必須 | 説明 |
+|----------------------|----------|------|------|
+| enabled | bool | はい | Matrix チャンネルの有効/無効 |
+| homeserver | string | はい | Matrix ホームサーバー URL(例:`https://matrix.org`) |
+| user_id | string | はい | ボットの Matrix ユーザー ID(例:`@bot:matrix.org`) |
+| access_token | string | はい | ボットのアクセストークン |
+| device_id | string | いいえ | オプションの Matrix デバイス ID |
+| join_on_invite | bool | いいえ | 招待されたルームに自動参加 |
+| allow_from | []string | いいえ | ユーザーホワイトリスト(Matrix ユーザー ID) |
+| group_trigger | object | いいえ | グループトリガー戦略(`mention_only` / `prefixes`) |
+| placeholder | object | いいえ | プレースホルダーメッセージ設定 |
+| reasoning_channel_id | string | いいえ | 推論出力のターゲットチャンネル |
+| message_format | string | いいえ | 出力形式:`"richtext"`(デフォルト)は markdown を HTML としてレンダリング;`"plain"` はプレーンテキストのみ送信 |
+
+## 3. 現在サポートされている機能
+
+- markdown レンダリング付きテキストメッセージ送受信(太字、斜体、見出し、コードブロックなど)
+- 設定可能なメッセージ形式(`richtext` / `plain`)
+- 受信画像/音声/動画/ファイルのダウンロード(MediaStore 優先、ローカルパスフォールバック)
+- 受信音声の既存文字起こしフローへの正規化(`[audio: ...]`)
+- 送信画像/音声/動画/ファイルのアップロードと送信
+- グループトリガールール(メンションのみモードを含む)
+- タイピング状態(`m.typing`)
+- プレースホルダーメッセージ + 最終返信の置き換え
+- 招待されたルームへの自動参加(無効化可能)
+
+## 4. TODO
+
+- リッチメディアメタデータの改善(例:画像/動画のサイズとサムネイル)
diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md
index 233f5c0a3..2ed19245a 100644
--- a/docs/channels/matrix/README.md
+++ b/docs/channels/matrix/README.md
@@ -1,3 +1,5 @@
+> Back to [README](../../../README.md)
+
# Matrix Channel Configuration Guide
## 1. Example Configuration
diff --git a/docs/channels/matrix/README.pt-br.md b/docs/channels/matrix/README.pt-br.md
new file mode 100644
index 000000000..11a9aaa11
--- /dev/null
+++ b/docs/channels/matrix/README.pt-br.md
@@ -0,0 +1,64 @@
+> Voltar ao [README](../../../README.pt-br.md)
+
+# Guia de Configuração do Canal Matrix
+
+## 1. Exemplo de Configuração
+
+Adicione isto ao `config.json`:
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking..."
+ },
+ "reasoning_channel_id": "",
+ "message_format": "richtext"
+ }
+ }
+}
+```
+
+## 2. Referência de Campos
+
+| Campo | Tipo | Obrigatório | Descrição |
+|----------------------|----------|-------------|-----------|
+| enabled | bool | Sim | Habilitar ou desabilitar o canal Matrix |
+| homeserver | string | Sim | URL do homeserver Matrix (por exemplo `https://matrix.org`) |
+| user_id | string | Sim | ID de usuário Matrix do bot (por exemplo `@bot:matrix.org`) |
+| access_token | string | Sim | Token de acesso do bot |
+| device_id | string | Não | ID de dispositivo Matrix opcional |
+| join_on_invite | bool | Não | Entrar automaticamente em salas convidadas |
+| allow_from | []string | Não | Lista branca de usuários (IDs Matrix) |
+| group_trigger | object | Não | Estratégia de gatilho de grupo (`mention_only` / `prefixes`) |
+| placeholder | object | Não | Configuração de mensagem de espaço reservado |
+| reasoning_channel_id | string | Não | Canal alvo para saída de raciocínio |
+| message_format | string | Não | Formato de saída: `"richtext"` (padrão) renderiza markdown como HTML; `"plain"` envia apenas texto simples |
+
+## 3. Suporte Atual
+
+- Envio/recebimento de mensagens de texto com renderização markdown (negrito, itálico, cabeçalhos, blocos de código, etc.)
+- Formato de mensagem configurável (`richtext` / `plain`)
+- Download de imagens/áudio/vídeo/arquivos recebidos (MediaStore primeiro, fallback para caminho local)
+- Normalização de áudio recebido no fluxo de transcrição existente (`[audio: ...]`)
+- Upload e envio de imagens/áudio/vídeo/arquivos de saída
+- Regras de gatilho de grupo (incluindo modo somente menção)
+- Estado de digitação (`m.typing`)
+- Mensagem de espaço reservado + substituição de resposta final
+- Entrada automática em salas convidadas (pode ser desabilitado)
+
+## 4. TODO
+
+- Melhorias nos metadados de mídia rica (por exemplo tamanho e miniaturas de imagens/vídeos)
diff --git a/docs/channels/matrix/README.vi.md b/docs/channels/matrix/README.vi.md
new file mode 100644
index 000000000..f1272076f
--- /dev/null
+++ b/docs/channels/matrix/README.vi.md
@@ -0,0 +1,64 @@
+> Quay lại [README](../../../README.vi.md)
+
+# Hướng dẫn Cấu hình Kênh Matrix
+
+## 1. Cấu hình Mẫu
+
+Thêm vào `config.json`:
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@your-bot:matrix.org",
+ "access_token": "YOUR_MATRIX_ACCESS_TOKEN",
+ "device_id": "",
+ "join_on_invite": true,
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "placeholder": {
+ "enabled": true,
+ "text": "Thinking..."
+ },
+ "reasoning_channel_id": "",
+ "message_format": "richtext"
+ }
+ }
+}
+```
+
+## 2. Tham chiếu Trường
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+|----------------------|----------|----------|-------|
+| enabled | bool | Có | Bật hoặc tắt kênh Matrix |
+| homeserver | string | Có | URL homeserver Matrix (ví dụ `https://matrix.org`) |
+| user_id | string | Có | ID người dùng Matrix của bot (ví dụ `@bot:matrix.org`) |
+| access_token | string | Có | Token truy cập của bot |
+| device_id | string | Không | ID thiết bị Matrix tùy chọn |
+| join_on_invite | bool | Không | Tự động tham gia phòng được mời |
+| allow_from | []string | Không | Danh sách trắng người dùng (ID Matrix) |
+| group_trigger | object | Không | Chiến lược kích hoạt nhóm (`mention_only` / `prefixes`) |
+| placeholder | object | Không | Cấu hình tin nhắn giữ chỗ |
+| reasoning_channel_id | string | Không | Kênh đích cho đầu ra suy luận |
+| message_format | string | Không | Định dạng đầu ra: `"richtext"` (mặc định) render markdown thành HTML; `"plain"` chỉ gửi văn bản thuần |
+
+## 3. Tính năng Hiện tại
+
+- Gửi/nhận tin nhắn văn bản với render markdown (đậm, nghiêng, tiêu đề, khối code, v.v.)
+- Định dạng tin nhắn có thể cấu hình (`richtext` / `plain`)
+- Tải xuống hình ảnh/âm thanh/video/tệp đến (MediaStore trước, fallback đường dẫn cục bộ)
+- Chuẩn hóa âm thanh đến vào luồng phiên âm hiện có (`[audio: ...]`)
+- Tải lên và gửi hình ảnh/âm thanh/video/tệp đi
+- Quy tắc kích hoạt nhóm (bao gồm chế độ chỉ đề cập)
+- Trạng thái đang gõ (`m.typing`)
+- Tin nhắn giữ chỗ + thay thế phản hồi cuối cùng
+- Tự động tham gia phòng được mời (có thể tắt)
+
+## 4. TODO
+
+- Cải thiện metadata phương tiện phong phú (ví dụ kích thước và hình thu nhỏ hình ảnh/video)
diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md
index 1f9e5bbe2..8db3e4383 100644
--- a/docs/channels/matrix/README.zh.md
+++ b/docs/channels/matrix/README.zh.md
@@ -1,3 +1,5 @@
+> 返回 [README](../../../README.zh.md)
+
# Matrix 通道配置指南
## 1. 配置示例
diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md
index a3e057ba4..86c016a5d 100644
--- a/docs/channels/telegram/README.md
+++ b/docs/channels/telegram/README.md
@@ -2,7 +2,7 @@
# Telegram
-The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription via Groq Whisper, and built-in command handling.
+The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../providers.md#voice-transcription)), and built-in command handling.
## Configuration
@@ -33,3 +33,23 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
3. Obtain the HTTP API Token
4. Fill in the Token in the configuration file
5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`)
+
+## Built-in Commands
+
+Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`.
+
+Skill-related commands:
+
+- `/list skills` lists the installed skills visible to the current agent.
+- `/use ` forces a skill for a single request.
+- `/use ` arms the skill for your next message in the same chat.
+- `/use clear` clears a pending skill override.
+
+Examples:
+
+```text
+/list skills
+/use git explain how to squash the last 3 commits
+/use git
+explain how to squash the last 3 commits
+```
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
index f50c712ce..1d9dcc46e 100644
--- a/docs/channels/telegram/README.zh.md
+++ b/docs/channels/telegram/README.zh.md
@@ -2,7 +2,7 @@
# Telegram
-Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
+Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../zh/providers.md#语音转录)),以及内置命令处理器。
## 配置
@@ -33,3 +33,23 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
3. 获取 HTTP API Token
4. 将 Token 填入配置文件中
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
+
+## 内置命令
+
+Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。
+
+与技能相关的命令:
+
+- `/list skills`:列出当前 Agent 可见的已安装技能。
+- `/use `:只在本次请求中强制使用指定技能。
+- `/use `:为同一聊天中的下一条消息预先启用该技能。
+- `/use clear`:清除待应用的技能覆盖。
+
+示例:
+
+```text
+/list skills
+/use git explain how to squash the last 3 commits
+/use italiapersonalfinance
+dammi le ultime news
+```
diff --git a/docs/chat-apps.md b/docs/chat-apps.md
index 07297952a..d300f5544 100644
--- a/docs/chat-apps.md
+++ b/docs/chat-apps.md
@@ -10,22 +10,23 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| Channel | Difficulty | Description | Documentation |
| -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
-| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) |
-| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) |
+| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](channels/telegram/README.md) |
+| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](channels/discord/README.md) |
| **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) |
-| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](../channels/weixin/README.md) |
-| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) |
-| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) |
-| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) |
-| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) |
-| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) |
-| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.md) / [App](../channels/wecom/wecom_app/README.md) / [AI Bot](../channels/wecom/wecom_aibot/README.md) |
-| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
-| **IRC** | ⭐⭐ Medium | Server + TLS configuration | - |
-| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
-| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
+| **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](#weixin) |
+| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](channels/slack/README.md) |
+| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](channels/matrix/README.md) |
+| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) |
+| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) |
+| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) |
+| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Group Bot (Webhook), custom App (API), AI Bot | [Bot](channels/wecom/wecom_bot/README.md) / [App](channels/wecom/wecom_app/README.md) / [AI Bot](channels/wecom/wecom_aibot/README.md) |
+| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) |
+| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
+| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) |
+| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](channels/maixcam/README.md) |
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
+
Telegram (Recommended)
@@ -44,7 +45,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
- "use_markdown_v2": false,
+ "use_markdown_v2": false
}
}
}
@@ -60,16 +61,24 @@ 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.
+PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) 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.
+You can also manage installed skills directly from Telegram:
+
+- `/list skills`
+- `/use `
+- `/use ` and then send the actual request in the next message
+- `/use clear`
+
**4. Advanced Formatting**
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
+
Discord
@@ -143,6 +152,7 @@ picoclaw gateway
+
WhatsApp (native via whatsmeow)
@@ -170,12 +180,14 @@ If `session_store_path` is empty, the session is stored in `/whatsapp
+
Weixin (WeChat Personal)
PicoClaw supports connecting to your personal WeChat account using the official Tencent iLink API.
**1. Login**
+
Run the interactive QR login flow:
```bash
picoclaw onboard weixin
@@ -183,6 +195,7 @@ picoclaw onboard weixin
Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config.
**2. Configure**
+
(Optional) Update `allow_from` with your WeChat User ID to restrict who can message the bot:
```json
{
@@ -203,6 +216,7 @@ picoclaw gateway
+
QQ
@@ -244,6 +258,7 @@ If you prefer to create the bot manually:
+
DingTalk
@@ -277,6 +292,7 @@ picoclaw gateway
```
+
Matrix
@@ -311,6 +327,7 @@ For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`,
+
LINE
@@ -359,6 +376,7 @@ picoclaw gateway
+
WeCom (企业微信)
@@ -473,6 +491,7 @@ picoclaw gateway
+
Feishu (Lark)
@@ -514,6 +533,7 @@ For full options, see [Feishu Channel Configuration Guide](channels/feishu/READM
+
Slack
@@ -547,6 +567,7 @@ picoclaw gateway
+
IRC
@@ -580,6 +601,7 @@ The bot will connect to the IRC server and join the specified channels.
+
OneBot (QQ via OneBot protocol)
diff --git a/docs/config-versioning.md b/docs/config-versioning.md
new file mode 100644
index 000000000..36d7fdd25
--- /dev/null
+++ b/docs/config-versioning.md
@@ -0,0 +1,230 @@
+# Config Schema Versioning Guide
+
+## Overview
+
+PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgrades as the configuration format evolves.
+
+## Version History
+
+### Version 1
+- **Introduction**: Initial version with version field support
+- **Changes**: Added `version` field to Config struct
+- **Migration**: No structural changes needed for existing configs
+
+## How It Works
+
+### Automatic Migration
+When you load a config file:
+1. The system first reads the `version` field from the JSON
+2. Based on the detected version, it loads the appropriate config struct (`ConfigV0`, `ConfigV1`, etc.)
+3. If the loaded version is less than the latest, migrations are applied incrementally
+4. The version number is updated automatically
+5. The migrated config is automatically saved back to disk
+
+### Version Field
+The `version` field in `config.json` indicates the schema version:
+- `0` or missing: Legacy config (no version field)
+- `1`: Current version with versioning support
+
+```json
+{
+ "version": 1,
+ "agents": {...},
+ ...
+}
+```
+
+## Adding a New Migration
+
+When making breaking changes to the config schema:
+
+### Step 1: Define the New Version Struct
+
+Create a new struct for the new version if the structure changes significantly:
+
+```go
+// ConfigV2 represents version 2 config structure
+type ConfigV2 struct {
+ Version int `json:"version"`
+ Agents AgentsConfig `json:"agents"`
+ // ... other fields with new structure
+}
+```
+
+### Step 2: Update Current Config Version
+
+```go
+const CurrentConfigVersion = 2 // Increment this
+```
+
+### Step 3: Add a Loader Function
+
+```go
+// loadConfigV2 loads a version 2 config
+func loadConfigV2(data []byte) (*Config, error) {
+ cfg := DefaultConfig()
+
+ // Parse to ConfigV2 struct
+ var v2 ConfigV2
+ if err := json.Unmarshal(data, &v2); err != nil {
+ return nil, err
+ }
+
+ // Convert to current Config
+ cfg.Version = v2.Version
+ cfg.Agents = v2.Agents
+ // ... map other fields
+
+ return cfg, nil
+}
+```
+
+### Step 4: Add Migration Logic
+
+```go
+// applyMigration applies a single migration step from fromVersion to toVersion
+func applyMigration(cfg *Config, fromVersion, toVersion int) (*Config, error) {
+ switch toVersion {
+ case 1:
+ // Migration from version 0 to 1
+ return &Config{
+ Version: 1,
+ Agents: cfg.Agents,
+ // ... copy all fields
+ }, nil
+ case 2:
+ // Migration from version 1 to 2
+ // Example: Move or rename fields
+ migrated := *cfg
+ migrated.Version = 2
+ // Apply structural changes
+ if cfg.SomeOldField != "" {
+ migrated.SomeNewField = cfg.SomeOldField
+ }
+ return &migrated, nil
+ default:
+ return nil, fmt.Errorf("unsupported migration target version: %d", toVersion)
+ }
+}
+```
+
+### Step 5: Update LoadConfig Switch
+
+```go
+func LoadConfig(path string) (*Config, error) {
+ // ... read file ...
+
+ switch versionInfo.Version {
+ case 0:
+ cfg, err = loadConfigV0(data)
+ case 1:
+ cfg, err = loadConfigV1(data)
+ case 2:
+ cfg, err = loadConfigV2(data)
+ default:
+ return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
+ }
+
+ // ... migrate and validate ...
+}
+```
+
+### Step 6: Test Your Migration
+
+Create a test in `config_migration_test.go`:
+
+```go
+func TestMigrateV1ToV2(t *testing.T) {
+ // Create a version 1 config
+ v1Config := Config{
+ Version: 1,
+ // ... set up test data
+ }
+
+ // Apply migration
+ migrated, err := applyMigration(&v1Config, 1, 2)
+ if err != nil {
+ t.Fatalf("Migration failed: %v", err)
+ }
+
+ // Verify version is updated
+ if migrated.Version != 2 {
+ t.Errorf("Expected version 2, got %d", migrated.Version)
+ }
+
+ // Verify data is preserved/transformed correctly
+ // ...
+}
+```
+
+## Migration Best Practices
+
+1. **Version-Specific Structs**: Define a separate struct for each version that has structural changes
+2. **Backward Compatibility**: Ensure old configs can still be loaded with their specific structs
+3. **No Data Loss**: Migrations should preserve all user settings
+4. **Idempotent**: Running the same migration multiple times should be safe
+5. **Auto-Save**: Migrated configs are automatically saved to update the user's file
+6. **Test Thoroughly**: Test with real user config files
+7. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
+
+## Example Migration
+
+### Scenario: Adding a new field with default value
+
+Old config (version 1):
+```json
+{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "max_tokens": 32768
+ }
+ }
+}
+```
+
+Migration to version 2:
+```go
+case 2:
+ migrated := *cfg
+ migrated.Version = 2
+
+ // Add new field with default value if not set
+ if migrated.Agents.Defaults.NewFeatureEnabled == false {
+ // Use default value
+ }
+
+ return &migrated, nil
+```
+
+New config (version 2):
+```json
+{
+ "version": 2,
+ "agents": {
+ "defaults": {
+ "max_tokens": 32768,
+ "new_feature_enabled": false
+ }
+ }
+}
+```
+
+## Troubleshooting
+
+### Config Not Upgrading
+- Check that `CurrentConfigVersion` is incremented
+- Verify migration logic in `applyMigration()` handles the target version
+- Ensure `migrateConfig()` is called in `LoadConfig()`
+
+### Migration Errors
+- Check error messages for specific migration failures
+- Review migration logic for edge cases
+- Ensure all required fields are properly initialized
+- Verify the loader function for the source version
+
+### Data Loss After Migration
+- Ensure all fields are copied during migration
+- Check that the migration doesn't overwrite values with defaults unnecessarily
+- Review the conversion logic in the loader functions
+
diff --git a/docs/configuration.md b/docs/configuration.md
index b5d652a85..f15a14c9a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -65,6 +65,24 @@ For advanced/test setups, you can override the builtin skills root with:
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
+### Using Skills From Chat Channels
+
+Once skills are installed, you can inspect and force them directly from a chat channel:
+
+- `/list skills` shows the installed skill names available to the current agent.
+- `/use ` forces a specific skill for a single request.
+- `/use ` arms that skill for your next message in the same chat session.
+- `/use clear` cancels a pending skill override created by `/use `.
+
+Examples:
+
+```text
+/list skills
+/use git explain how to squash the last 3 commits
+/use italiapersonalfinance
+dammi le ultime news
+```
+
### Unified Command Execution Policy
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
@@ -347,3 +365,396 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
```markdown
# Periodic Tasks
+
+## Quick Tasks (respond directly)
+
+- Report current time
+
+## Long Tasks (use spawn for async)
+
+- Search the web for AI news and summarize
+- Check email and report important messages
+```
+
+**Key behaviors:**
+
+| Feature | Description |
+| ----------------------- | --------------------------------------------------------- |
+| **spawn** | Creates async subagent, doesn't block heartbeat |
+| **Independent context** | Subagent has its own context, no session history |
+| **message tool** | Subagent communicates with user directly via message tool |
+| **Non-blocking** | After spawning, heartbeat continues to next task |
+
+#### How Subagent Communication Works
+
+```
+Heartbeat triggers
+ ↓
+Agent reads HEARTBEAT.md
+ ↓
+For long task: spawn subagent
+ ↓ ↓
+Continue to next task Subagent works independently
+ ↓ ↓
+All tasks done Subagent uses "message" tool
+ ↓ ↓
+Respond HEARTBEAT_OK User receives result directly
+```
+
+The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.
+
+**Configuration:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Option | Default | Description |
+| ---------- | ------- | ---------------------------------- |
+| `enabled` | `true` | Enable/disable heartbeat |
+| `interval` | `30` | Check interval in minutes (min: 5) |
+
+**Environment variables:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
+
+### Providers
+
+> [!NOTE]
+> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
+
+| Provider | Purpose | Get API Key |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
+
+### Model Configuration (model_list)
+
+> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers — **zero code changes required!**
+
+This design also enables **multi-agent support** with flexible provider selection:
+
+- **Different agents, different providers**: Each agent can use its own LLM provider
+- **Model fallbacks**: Configure primary and fallback models for resilience
+- **Load balancing**: Distribute requests across multiple endpoints
+- **Centralized configuration**: Manage all providers in one place
+
+#### All Supported Vendors
+
+| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
+| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
+| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
+| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
+| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
+| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
+| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
+
+#### Basic Configuration
+
+```json
+{
+ "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": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.4"
+ }
+ }
+}
+```
+
+#### Vendor-Specific Examples
+
+
+OpenAI
+
+```json
+{
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+VolcEngine (Doubao)
+
+```json
+{
+ "model_name": "ark-code-latest",
+ "model": "volcengine/ark-code-latest",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+智谱 AI (GLM)
+
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+
+
+
+DeepSeek
+
+```json
+{
+ "model_name": "deepseek-chat",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+Anthropic
+
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+}
+```
+
+> Run `picoclaw auth login --provider anthropic` to paste your API token.
+
+For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
+
+```json
+{
+ "model_name": "claude-opus-4-6",
+ "model": "anthropic-messages/claude-opus-4-6",
+ "api_key": "sk-ant-your-key",
+ "api_base": "https://api.anthropic.com"
+}
+```
+
+> Use `anthropic-messages` when the endpoint requires Anthropic's native `/v1/messages` format instead of OpenAI-compatible `/v1/chat/completions`.
+
+
+
+
+Ollama (local)
+
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+
+
+
+Custom Proxy / LiteLLM
+
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
+
+
+
+#### Load Balancing
+
+Configure multiple endpoints for the same model name — PicoClaw will automatically round-robin between them:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migration from Legacy `providers` Config
+
+The old `providers` configuration is **deprecated** but still supported for backward compatibility. See [docs/migration/model-list-migration.md](../migration/model-list-migration.md) for the full guide.
+
+### Provider Architecture
+
+PicoClaw routes providers by protocol family:
+
+- **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others.
+- **Anthropic**: Claude-native API behavior.
+- **Codex/OAuth**: OpenAI OAuth/token authentication route.
+
+This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
+
+
+Zhipu (legacy providers format)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+
+
+
+Full config example
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "session": {
+ "dm_scope": "per-channel-peer",
+ "backlog_limit": 20
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ }
+ },
+ "tools": {
+ "web": {
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Scheduled Tasks / Reminders
+
+PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can set, list, and cancel reminders or recurring jobs that trigger at specified times.
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
+
+### Advanced Topics
+
+| Topic | Description |
+| ----- | ----------- |
+| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks |
+| [Steering](steering.md) | Inject messages into a running agent loop between tool calls |
+| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle |
+| [Context Management](agent-refactor/context.md) | Context boundary detection, proactive budget check, compression |
diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md
index 67422e0ec..daff951f4 100644
--- a/docs/fr/chat-apps.md
+++ b/docs/fr/chat-apps.md
@@ -13,6 +13,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
| **Telegram** | ⭐ Facile | Recommandé, transcription vocale, long polling (pas d'IP publique requise) | [Documentation](../channels/telegram/README.fr.md) |
| **Discord** | ⭐ Facile | Socket Mode, groupes/DM, écosystème bot riche | [Documentation](../channels/discord/README.fr.md) |
| **WhatsApp** | ⭐ Facile | Natif (scan QR) ou Bridge URL | [Documentation](#whatsapp) |
+| **Weixin** | ⭐ Facile | Scan QR natif (API Tencent iLink) | [Documentation](#weixin) |
| **Slack** | ⭐ Facile | **Socket Mode** (pas d'IP publique requise), entreprise | [Documentation](../channels/slack/README.fr.md) |
| **Matrix** | ⭐⭐ Moyen | Protocole fédéré, auto-hébergement possible | [Documentation](../channels/matrix/README.fr.md) |
| **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) |
@@ -20,11 +21,12 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
| **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) |
| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/README.fr.md) |
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
-| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | - |
+| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
+
Telegram (Recommandé)
@@ -65,6 +67,7 @@ Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le
+
Discord
@@ -138,6 +141,7 @@ picoclaw gateway
+
WhatsApp (natif via whatsmeow)
@@ -165,6 +169,43 @@ Si `session_store_path` est vide, la session est stockée dans `/what
+
+
+Weixin (WeChat Personnel)
+
+PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API officielle Tencent iLink.
+
+**1. Connexion**
+
+Lancez le flux de connexion interactif par QR code :
+```bash
+picoclaw onboard weixin
+```
+Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration.
+
+**2. Configurer**
+
+(Optionnel) Ajoutez votre identifiant utilisateur WeChat dans `allow_from` pour restreindre qui peut envoyer des messages au bot :
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. Lancer**
+```bash
+picoclaw gateway
+```
+
+
+
+
QQ
@@ -206,6 +247,7 @@ Si vous préférez créer le bot manuellement :
+
DingTalk
@@ -239,6 +281,7 @@ picoclaw gateway
```
+
Matrix
@@ -273,6 +316,7 @@ Pour toutes les options (`device_id`, `join_on_invite`, `group_trigger`, `placeh
+
LINE
@@ -321,6 +365,7 @@ picoclaw gateway
+
WeCom (企业微信)
@@ -435,6 +480,7 @@ picoclaw gateway
+
Feishu (飞书)
@@ -476,6 +522,7 @@ Pour toutes les options, voir le [Guide de Configuration du Canal Feishu](../cha
+
Slack
@@ -509,6 +556,7 @@ picoclaw gateway
+
IRC
@@ -542,6 +590,7 @@ Le bot se connectera au serveur IRC et rejoindra les canaux spécifiés.
+
OneBot (QQ via protocole OneBot)
@@ -580,6 +629,7 @@ picoclaw gateway
+
MaixCam
diff --git a/docs/fr/configuration.md b/docs/fr/configuration.md
index d56da2cad..8d94620ba 100644
--- a/docs/fr/configuration.md
+++ b/docs/fr/configuration.md
@@ -214,5 +214,150 @@ L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera toute
Pour les tâches longues (recherche web, appels API), utilisez l'outil `spawn` pour créer un **subagent** :
```markdown
-# Periodic Tasks
+# Tâches Périodiques
+
+## Tâches Rapides (répondre directement)
+
+- Indiquer l'heure actuelle
+
+## Tâches Longues (utiliser spawn pour l'asynchrone)
+
+- Rechercher les actualités IA sur le web et résumer
+- Vérifier les e-mails et signaler les messages importants
```
+
+**Comportements clés :**
+
+| Fonctionnalité | Description |
+| ---------------- | ------------------------------------------------------------------ |
+| **spawn** | Crée un subagent asynchrone, ne bloque pas le heartbeat |
+| **Contexte indépendant** | Le subagent a son propre contexte, sans historique de session |
+| **message tool** | Le subagent communique directement avec l'utilisateur |
+| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante |
+
+#### Flux de Communication du Subagent
+
+```
+Heartbeat déclenché
+ ↓
+Agent lit HEARTBEAT.md
+ ↓
+Tâche longue : spawn subagent
+ ↓ ↓
+Continue tâche suivante Subagent travaille indépendamment
+ ↓ ↓
+Toutes tâches terminées Subagent utilise "message" tool
+ ↓ ↓
+Répond HEARTBEAT_OK Utilisateur reçoit le résultat
+```
+
+**Configuration :**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Option | Défaut | Description |
+| ---------- | ------ | ---------------------------------------- |
+| `enabled` | `true` | Activer/désactiver le heartbeat |
+| `interval` | `30` | Intervalle en minutes (minimum : 5) |
+
+**Variables d'environnement :**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour changer l'intervalle
+
+### Providers
+
+> [!NOTE]
+> Groq fournit une transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
+
+| Provider | Usage | Obtenir une clé API |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM (Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM (recommandé, accès à tous modèles) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **Transcription vocale** (Whisper)| [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
+
+### Configuration des Modèles (model_list)
+
+> **Nouveauté :** PicoClaw utilise désormais une approche **centrée sur le modèle**. Spécifiez simplement le format `vendor/model` (ex. `zhipu/glm-4.7`) pour ajouter de nouveaux providers — **aucune modification de code requise !**
+
+#### Tous les Vendors Supportés
+
+| Vendor | Préfixe `model` | API Base par défaut | Protocole | API Key |
+| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir](https://openrouter.ai/keys) |
+| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
+
+#### Équilibrage de Charge
+
+Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectuera automatiquement un round-robin :
+
+```json
+{
+ "model_list": [
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
+ ]
+}
+```
+
+#### Migration depuis l'ancienne config `providers`
+
+L'ancienne configuration `providers` est **dépréciée** mais toujours supportée. Voir [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
+
+### Architecture des Providers
+
+PicoClaw route les providers par famille de protocole :
+
+- **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres.
+- **Anthropic** : Comportement natif de l'API Claude.
+- **Codex/OAuth** : Route d'authentification OAuth/token OpenAI.
+
+### Tâches Planifiées / Rappels
+
+PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes.
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspace/cron/`.
+
+### Sujets Avancés
+
+| Sujet | Description |
+| ----- | ----------- |
+| [Système de Hooks](../hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation |
+| [Steering](../steering.md) | Injecter des messages dans une boucle agent en cours d'exécution |
+| [SubTurn](../subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie |
+| [Gestion du Contexte](../agent-refactor/context.md) | Détection des limites de contexte, compression |
diff --git a/docs/fr/tools_configuration.md b/docs/fr/tools_configuration.md
index f6e1c0374..1324d49e5 100644
--- a/docs/fr/tools_configuration.md
+++ b/docs/fr/tools_configuration.md
@@ -41,14 +41,6 @@ Paramètres généraux pour la récupération et le traitement du contenu des pa
| `fetch_limit_bytes` | int | 10485760 | Taille maximale du contenu de la page web à récupérer, en octets (par défaut 10 Mo). |
| `format` | string | "plaintext" | Format de sortie du contenu récupéré. Options : `plaintext` ou `markdown` (recommandé). |
-### Brave
-
-| Config | Type | Par défaut | Description |
-|---------------|--------|------------|---------------------------|
-| `enabled` | bool | false | Activer la recherche Brave |
-| `api_key` | string | - | Clé API Brave Search |
-| `max_results` | int | 5 | Nombre maximum de résultats |
-
### DuckDuckGo
| Config | Type | Par défaut | Description |
@@ -56,13 +48,73 @@ Paramètres généraux pour la récupération et le traitement du contenu des pa
| `enabled` | bool | true | Activer la recherche DuckDuckGo |
| `max_results` | int | 5 | Nombre maximum de résultats |
+### Baidu Search
+
+| Config | Type | Par défaut | Description |
+|---------------|--------|-----------------------------------------------------------------|------------------------------------|
+| `enabled` | bool | false | Activer la recherche Baidu |
+| `api_key` | string | - | Clé API Qianfan |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL de l'API Baidu Search |
+| `max_results` | int | 10 | Nombre maximum de résultats |
+
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
+
### Perplexity
| Config | Type | Par défaut | Description |
|---------------|--------|------------|--------------------------------|
-| `enabled` | bool | false | Activer la recherche Perplexity |
-| `api_key` | string | - | Clé API Perplexity |
-| `max_results` | int | 5 | Nombre maximum de résultats |
+| `enabled` | bool | false | Activer la recherche Perplexity |
+| `api_key` | string | - | Clé API Perplexity |
+| `api_keys` | string[] | - | Plusieurs clés API Perplexity pour la rotation (`api_key` prioritaire) |
+| `max_results` | int | 5 | Nombre maximum de résultats |
+
+### Brave
+
+| Config | Type | Par défaut | Description |
+|---------------|--------|------------|---------------------------|
+| `enabled` | bool | false | Activer la recherche Brave |
+| `api_key` | string | - | Clé API Brave Search |
+| `api_keys` | string[] | - | Plusieurs clés API Brave Search pour la rotation (`api_key` prioritaire) |
+| `max_results` | int | 5 | Nombre maximum de résultats |
+
+### Tavily
+
+| Config | Type | Par défaut | Description |
+|---------------|--------|------------|------------------------------------|
+| `enabled` | bool | false | Activer la recherche Tavily |
+| `api_key` | string | - | Clé API Tavily |
+| `base_url` | string | - | URL de base Tavily personnalisée |
+| `max_results` | int | 0 | Nombre maximum de résultats (0 = défaut) |
+
+### SearXNG
+
+| Config | Type | Par défaut | Description |
+|---------------|--------|--------------------------|--------------------------------|
+| `enabled` | bool | false | Activer la recherche SearXNG |
+| `base_url` | string | `http://localhost:8888` | URL de l'instance SearXNG |
+| `max_results` | int | 5 | Nombre maximum de résultats |
+
+### GLM Search
+
+| Config | Type | Par défaut | Description |
+|-----------------|--------|------------------------------------------------------|---------------------------|
+| `enabled` | bool | false | Activer GLM Search |
+| `api_key` | string | - | Clé API GLM |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL de l'API GLM Search |
+| `search_engine` | string | `search_std` | Type de moteur de recherche |
+| `max_results` | int | 5 | Nombre maximum de résultats |
## Outil Exec
diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md
index 997a064ff..789c0125f 100644
--- a/docs/ja/chat-apps.md
+++ b/docs/ja/chat-apps.md
@@ -15,6 +15,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
| **Telegram** | ⭐ 簡単 | 推奨、音声テキスト変換対応、ロングポーリング(公開 IP 不要) | [ドキュメント](../channels/telegram/README.ja.md) |
| **Discord** | ⭐ 簡単 | Socket Mode、グループ/DM 対応、Bot エコシステム充実 | [ドキュメント](../channels/discord/README.ja.md) |
| **WhatsApp** | ⭐ 簡単 | ネイティブ (QR スキャン) または Bridge URL | [ドキュメント](#whatsapp) |
+| **微信 (Weixin)** | ⭐ 簡単 | ネイティブ QR スキャン(Tencent iLink API)| [ドキュメント](#weixin) |
| **Slack** | ⭐ 簡単 | **Socket Mode** (公開 IP 不要)、エンタープライズ対応 | [ドキュメント](../channels/slack/README.ja.md) |
| **Matrix** | ⭐⭐ 中程度 | フェデレーションプロトコル、セルフホスト対応 | [ドキュメント](../channels/matrix/README.ja.md) |
| **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) |
@@ -22,13 +23,14 @@ PicoClaw は複数のチャットプラットフォームをサポートして
| **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) |
| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/README.ja.md) |
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
-| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | - |
+| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
---
+
Telegram (推奨)
@@ -69,6 +71,7 @@ Telegram 側はコマンドメニュー登録機能を保持し、汎用コマ
+
Discord
@@ -143,6 +146,7 @@ picoclaw gateway
+
WhatsApp (ネイティブ whatsmeow)
@@ -170,6 +174,43 @@ PicoClaw は 2 つの WhatsApp 接続方式をサポートしています:
+
+
+微信 (Weixin)
+
+PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウントへの接続をサポートしています。
+
+**1. ログイン**
+
+インタラクティブな QR ログインフローを実行します:
+```bash
+picoclaw onboard weixin
+```
+WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。
+
+**2. 設定**
+
+(オプション)ボットと会話できるユーザーを制限するために `allow_from` に WeChat ユーザー ID を追加します:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. 実行**
+```bash
+picoclaw gateway
+```
+
+
+
+
Matrix
@@ -204,6 +245,7 @@ picoclaw gateway
+
QQ
@@ -245,6 +287,7 @@ QQ 開放プラットフォームでは、OpenClaw 互換ボットのワンク
+
Slack
@@ -278,6 +321,7 @@ picoclaw gateway
+
IRC
@@ -311,6 +355,7 @@ picoclaw gateway
+
DingTalk
@@ -345,6 +390,7 @@ picoclaw gateway
+
LINE
@@ -393,6 +439,7 @@ picoclaw gateway
+
Feishu (飛書)
@@ -434,6 +481,7 @@ picoclaw gateway
+
WeCom (企業微信)
@@ -548,6 +596,7 @@ picoclaw gateway
+
OneBot(OneBot プロトコル経由の QQ)
@@ -586,6 +635,7 @@ picoclaw gateway
+
MaixCam
diff --git a/docs/ja/configuration.md b/docs/ja/configuration.md
index 215b35d54..35676809e 100644
--- a/docs/ja/configuration.md
+++ b/docs/ja/configuration.md
@@ -256,3 +256,109 @@ Agent は 30 分ごと(設定可能)にこのファイルを読み取り、
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔を変更
+
+#### サブ Agent の通信フロー
+
+```
+ハートビート起動
+ ↓
+Agent が HEARTBEAT.md を読む
+ ↓
+長時間タスク:spawn サブ Agent
+ ↓ ↓
+次のタスクへ継続 サブ Agent が独立して動作
+ ↓ ↓
+全タスク完了 サブ Agent が "message" ツールを使用
+ ↓ ↓
+HEARTBEAT_OK を返信 ユーザーが直接結果を受信
+```
+
+### Providers
+
+> [!NOTE]
+> Groq は Whisper による無料音声文字起こしを提供します。設定すると、任意のチャンネルの音声メッセージが Agent レベルで自動的に文字起こしされます。
+
+| Provider | 用途 | API キー取得 |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM(Qwen 直接) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM(Vivgrid 直接) | [vivgrid.com](https://vivgrid.com) |
+
+### モデル設定 (model_list)
+
+> **新機能:** PicoClaw は**モデル中心**の設定アプローチを採用しました。`vendor/model` 形式(例:`zhipu/glm-4.7`)を指定するだけで新しい Provider を追加できます — **コード変更不要!**
+
+#### サポートされている全 Vendor
+
+| Vendor | `model` プレフィックス | デフォルト API Base | プロトコル | API Key |
+| ----------------------- | ---------------------- | --------------------------------------------------- | ---------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [取得](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) |
+| **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [取得](https://openrouter.ai/keys) |
+| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [取得](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth のみ |
+
+#### ロードバランシング
+
+同じモデル名に複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンします:
+
+```json
+{
+ "model_list": [
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
+ ]
+}
+```
+
+#### 旧 `providers` 設定からの移行
+
+旧 `providers` 設定は**非推奨**ですが後方互換性のためサポートされています。[docs/migration/model-list-migration.md](../migration/model-list-migration.md) を参照してください。
+
+### Provider アーキテクチャ
+
+PicoClaw はプロトコルファミリーで Provider をルーティングします:
+
+- **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。
+- **Anthropic**:Claude ネイティブ API の動作。
+- **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。
+
+### スケジュールタスク / リマインダー
+
+PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+スケジュールタスクは再起動後も `~/.picoclaw/workspace/cron/` に保存されます。
+
+### 高度なトピック
+
+| トピック | 説明 |
+| -------- | ---- |
+| [Hook システム](../hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook |
+| [Steering](../steering.md) | 実行中の Agent ループにメッセージを注入 |
+| [SubTurn](../subturn.md) | サブ Agent の調整、並行制御、ライフサイクル |
+| [コンテキスト管理](../agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 |
diff --git a/docs/ja/tools_configuration.md b/docs/ja/tools_configuration.md
index c40e58538..c946bf088 100644
--- a/docs/ja/tools_configuration.md
+++ b/docs/ja/tools_configuration.md
@@ -41,14 +41,6 @@ Web ツールはウェブ検索とフェッチに使用されます。
| `fetch_limit_bytes` | int | 10485760 | 取得するウェブページペイロードの最大サイズ(バイト単位、デフォルトは10MB)。 |
| `format` | string | "plaintext" | 取得コンテンツの出力形式。オプション:`plaintext` または `markdown`(推奨)。 |
-### Brave
-
-| 設定項目 | 型 | デフォルト | 説明 |
-|---------------|--------|------------|-----------------------|
-| `enabled` | bool | false | Brave 検索を有効にする |
-| `api_key` | string | - | Brave Search API キー |
-| `max_results` | int | 5 | 最大結果数 |
-
### DuckDuckGo
| 設定項目 | 型 | デフォルト | 説明 |
@@ -56,13 +48,73 @@ Web ツールはウェブ検索とフェッチに使用されます。
| `enabled` | bool | true | DuckDuckGo 検索を有効にする |
| `max_results` | int | 5 | 最大結果数 |
+### Baidu Search
+
+| 設定項目 | 型 | デフォルト | 説明 |
+|---------------|--------|-----------------------------------------------------------------|-------------------------------|
+| `enabled` | bool | false | Baidu 検索を有効にする |
+| `api_key` | string | - | Qianfan API キー |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
+| `max_results` | int | 10 | 最大結果数 |
+
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
+
### Perplexity
| 設定項目 | 型 | デフォルト | 説明 |
|---------------|--------|------------|---------------------------|
-| `enabled` | bool | false | Perplexity 検索を有効にする |
-| `api_key` | string | - | Perplexity API キー |
-| `max_results` | int | 5 | 最大結果数 |
+| `enabled` | bool | false | Perplexity 検索を有効にする |
+| `api_key` | string | - | Perplexity API キー |
+| `api_keys` | string[] | - | 複数の Perplexity API キー(ローテーション用、`api_key` より優先) |
+| `max_results` | int | 5 | 最大結果数 |
+
+### Brave
+
+| 設定項目 | 型 | デフォルト | 説明 |
+|---------------|--------|------------|-----------------------|
+| `enabled` | bool | false | Brave 検索を有効にする |
+| `api_key` | string | - | Brave Search API キー |
+| `api_keys` | string[] | - | 複数の Brave Search API キー(ローテーション用、`api_key` より優先) |
+| `max_results` | int | 5 | 最大結果数 |
+
+### Tavily
+
+| 設定項目 | 型 | デフォルト | 説明 |
+|---------------|--------|------------|-----------------------------------|
+| `enabled` | bool | false | Tavily 検索を有効にする |
+| `api_key` | string | - | Tavily API キー |
+| `base_url` | string | - | カスタム Tavily API ベース URL |
+| `max_results` | int | 0 | 最大結果数(0 = デフォルト) |
+
+### SearXNG
+
+| 設定項目 | 型 | デフォルト | 説明 |
+|---------------|--------|--------------------------|---------------------------|
+| `enabled` | bool | false | SearXNG 検索を有効にする |
+| `base_url` | string | `http://localhost:8888` | SearXNG インスタンス URL |
+| `max_results` | int | 5 | 最大結果数 |
+
+### GLM Search
+
+| 設定項目 | 型 | デフォルト | 説明 |
+|-----------------|--------|------------------------------------------------------|---------------------------|
+| `enabled` | bool | false | GLM Search を有効にする |
+| `api_key` | string | - | GLM API キー |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
+| `search_engine` | string | `search_std` | 検索エンジンタイプ |
+| `max_results` | int | 5 | 最大結果数 |
## Exec ツール
diff --git a/docs/providers.md b/docs/providers.md
index dde1814fb..3a740d3b8 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -5,7 +5,7 @@
### Providers
> [!NOTE]
-> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
+> Voice transcription can use a configured multimodal model via `voice.model_name`. Groq Whisper remains available as a fallback when no voice model is configured.
| Provider | Purpose | Get API Key |
| ------------ | --------------------------------------- | ------------------------------------------------------------ |
@@ -101,6 +101,33 @@ This design also enables **multi-agent support** with flexible provider selectio
}
```
+#### Voice Transcription
+
+You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
+
+If `voice.model_name` is not configured, PicoClaw will continue to fall back to Groq transcription when a Groq API key is available.
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "voice-gemini",
+ "model": "gemini/gemini-2.5-flash",
+ "api_key": "your-gemini-key"
+ }
+ ],
+ "voice": {
+ "model_name": "voice-gemini",
+ "echo_transcription": false
+ },
+ "providers": {
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ }
+}
+```
+
#### Vendor-Specific Examples
**OpenAI**
@@ -344,6 +371,10 @@ picoclaw agent -m "Hello"
"api_key": "gsk_xxx"
}
},
+ "voice": {
+ "model_name": "voice-gemini",
+ "echo_transcription": false
+ },
"channels": {
"telegram": {
"enabled": true,
diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md
index 08ef292fa..4fa59b1b2 100644
--- a/docs/pt-br/chat-apps.md
+++ b/docs/pt-br/chat-apps.md
@@ -13,6 +13,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
| **Telegram** | ⭐ Fácil | Recomendado, voz para texto, long polling (sem IP público) | [Documentação](../channels/telegram/README.pt-br.md) |
| **Discord** | ⭐ Fácil | Socket Mode, suporte a grupos/DM, ecossistema bot rico | [Documentação](../channels/discord/README.pt-br.md) |
| **WhatsApp** | ⭐ Fácil | Nativo (scan QR) ou Bridge URL | [Documentação](#whatsapp) |
+| **Weixin** | ⭐ Fácil | Scan QR nativo (API Tencent iLink) | [Documentação](#weixin) |
| **Slack** | ⭐ Fácil | **Socket Mode** (sem IP público), empresarial | [Documentação](../channels/slack/README.pt-br.md) |
| **Matrix** | ⭐⭐ Médio | Protocolo federado, suporte a auto-hospedagem | [Documentação](../channels/matrix/README.pt-br.md) |
| **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) |
@@ -20,11 +21,12 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
| **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) |
| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) |
| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
-| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | - |
+| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) |
| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
+
Telegram (Recomendado)
@@ -65,6 +67,7 @@ Se o registro de comandos falhar (erros transitórios de rede/API), o canal aind
+
Discord
@@ -138,6 +141,7 @@ picoclaw gateway
+
WhatsApp (nativo via whatsmeow)
@@ -165,6 +169,43 @@ Se `session_store_path` estiver vazio, a sessão é armazenada em `/w
+
+
+Weixin (WeChat Pessoal)
+
+O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial Tencent iLink.
+
+**1. Login**
+
+Execute o fluxo de login interativo por QR code:
+```bash
+picoclaw onboard weixin
+```
+Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração.
+
+**2. Configurar**
+
+(Opcional) Adicione seu ID de usuário WeChat em `allow_from` para restringir quem pode enviar mensagens ao bot:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. Executar**
+```bash
+picoclaw gateway
+```
+
+
+
+
QQ
@@ -206,6 +247,7 @@ Se preferir criar o bot manualmente:
+
DingTalk
@@ -240,6 +282,7 @@ picoclaw gateway
+
MaixCam
@@ -262,6 +305,7 @@ picoclaw gateway
+
Matrix
@@ -296,6 +340,7 @@ Para opções completas (`device_id`, `join_on_invite`, `group_trigger`, `placeh
+
LINE
@@ -344,6 +389,7 @@ picoclaw gateway
+
WeCom (企业微信)
@@ -457,6 +503,7 @@ picoclaw gateway
+
Feishu (Lark)
@@ -498,6 +545,7 @@ Para opções completas, veja o [Guia de Configuração do Canal Feishu](../chan
+
Slack
@@ -531,6 +579,7 @@ picoclaw gateway
+
IRC
@@ -564,6 +613,7 @@ O bot se conectará ao servidor IRC e entrará nos canais especificados.
+
OneBot (QQ via protocolo OneBot)
diff --git a/docs/pt-br/configuration.md b/docs/pt-br/configuration.md
index ee14ca724..ff3ce2b34 100644
--- a/docs/pt-br/configuration.md
+++ b/docs/pt-br/configuration.md
@@ -216,4 +216,149 @@ Para tarefas de longa duração (busca na web, chamadas de API), use a ferrament
```markdown
# Tarefas Periódicas
+
+## Tarefas Rápidas (responder diretamente)
+
+- Informar a hora atual
+
+## Tarefas Longas (usar spawn para assíncrono)
+
+- Pesquisar notícias de IA na web e resumir
+- Verificar e-mails e reportar mensagens importantes
```
+
+**Comportamentos principais:**
+
+| Funcionalidade | Descrição |
+| ---------------- | ------------------------------------------------------------------ |
+| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat |
+| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão |
+| **message tool** | Subagente comunica diretamente com o usuário via message tool |
+| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
+
+#### Fluxo de Comunicação do Subagente
+
+```
+Heartbeat disparado
+ ↓
+Agent lê HEARTBEAT.md
+ ↓
+Tarefa longa: spawn subagente
+ ↓ ↓
+Continua próxima tarefa Subagente trabalha independentemente
+ ↓ ↓
+Todas tarefas concluídas Subagente usa ferramenta "message"
+ ↓ ↓
+Responde HEARTBEAT_OK Usuário recebe resultado diretamente
+```
+
+**Configuração:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Opção | Padrão | Descrição |
+| ---------- | ------ | -------------------------------------- |
+| `enabled` | `true` | Ativar/desativar heartbeat |
+| `interval` | `30` | Intervalo em minutos (mínimo: 5) |
+
+**Variáveis de ambiente:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` para desativar
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
+
+### Providers
+
+> [!NOTE]
+> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de áudio de qualquer canal serão automaticamente transcritas no nível do agente.
+
+| Provider | Finalidade | Obter API Key |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM (Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM (recomendado, acesso a todos modelos) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen direto) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direto) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid direto) | [vivgrid.com](https://vivgrid.com) |
+
+### Configuração de Modelos (model_list)
+
+> **Novidade:** PicoClaw agora usa uma abordagem **centrada no modelo**. Basta especificar o formato `vendor/model` (ex.: `zhipu/glm-4.7`) para adicionar novos providers — **sem alterações de código!**
+
+#### Todos os Vendors Suportados
+
+| Vendor | Prefixo `model` | API Base padrão | Protocolo | API Key |
+| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter](https://openrouter.ai/keys) |
+| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | Somente OAuth |
+
+#### Balanceamento de Carga
+
+Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará round-robin automaticamente:
+
+```json
+{
+ "model_list": [
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
+ ]
+}
+```
+
+#### Migração da Configuração Legada `providers`
+
+A configuração antiga `providers` está **depreciada** mas ainda é suportada. Veja [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
+
+### Arquitetura de Providers
+
+PicoClaw roteia providers por família de protocolo:
+
+- **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros.
+- **Anthropic**: Comportamento nativo da API Claude.
+- **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI.
+
+### Tarefas Agendadas / Lembretes
+
+PicoClaw suporta tarefas agendadas via ferramenta `cron`.
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspace/cron/`.
+
+### Tópicos Avançados
+
+| Tópico | Descrição |
+| ------ | --------- |
+| [Sistema de Hooks](../hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação |
+| [Steering](../steering.md) | Injetar mensagens em um loop de agente em execução |
+| [SubTurn](../subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida |
+| [Gerenciamento de Contexto](../agent-refactor/context.md) | Detecção de limites de contexto, compressão |
diff --git a/docs/pt-br/tools_configuration.md b/docs/pt-br/tools_configuration.md
index 2cc4f3999..feec3c3d8 100644
--- a/docs/pt-br/tools_configuration.md
+++ b/docs/pt-br/tools_configuration.md
@@ -41,14 +41,6 @@ Configurações gerais para busca e processamento de conteúdo de páginas web.
| `fetch_limit_bytes` | int | 10485760 | Tamanho máximo do payload da página web a ser buscado, em bytes (padrão é 10MB). |
| `format` | string | "plaintext" | Formato de saída do conteúdo buscado. Opções: `plaintext` ou `markdown` (recomendado). |
-### Brave
-
-| Config | Tipo | Padrão | Descrição |
-|---------------|--------|--------|----------------------------|
-| `enabled` | bool | false | Habilitar pesquisa Brave |
-| `api_key` | string | - | Chave API do Brave Search |
-| `max_results` | int | 5 | Número máximo de resultados |
-
### DuckDuckGo
| Config | Tipo | Padrão | Descrição |
@@ -56,13 +48,73 @@ Configurações gerais para busca e processamento de conteúdo de páginas web.
| `enabled` | bool | true | Habilitar pesquisa DuckDuckGo |
| `max_results` | int | 5 | Número máximo de resultados |
+### Baidu Search
+
+| Config | Tipo | Padrão | Descrição |
+|---------------|--------|-----------------------------------------------------------------|------------------------------------|
+| `enabled` | bool | false | Habilitar pesquisa Baidu |
+| `api_key` | string | - | Chave API Qianfan |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL da API Baidu Search |
+| `max_results` | int | 10 | Número máximo de resultados |
+
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
+
### Perplexity
| Config | Tipo | Padrão | Descrição |
|---------------|--------|--------|--------------------------------|
-| `enabled` | bool | false | Habilitar pesquisa Perplexity |
-| `api_key` | string | - | Chave API do Perplexity |
-| `max_results` | int | 5 | Número máximo de resultados |
+| `enabled` | bool | false | Habilitar pesquisa Perplexity |
+| `api_key` | string | - | Chave API do Perplexity |
+| `api_keys` | string[] | - | Várias chaves API do Perplexity para rotação (prioridade sobre `api_key`) |
+| `max_results` | int | 5 | Número máximo de resultados |
+
+### Brave
+
+| Config | Tipo | Padrão | Descrição |
+|---------------|--------|--------|----------------------------|
+| `enabled` | bool | false | Habilitar pesquisa Brave |
+| `api_key` | string | - | Chave API única do Brave Search |
+| `api_keys` | string[] | - | Várias chaves API do Brave para rotação (prioridade sobre `api_key`) |
+| `max_results` | int | 5 | Número máximo de resultados |
+
+### Tavily
+
+| Config | Tipo | Padrão | Descrição |
+|---------------|--------|--------|------------------------------------|
+| `enabled` | bool | false | Habilitar pesquisa Tavily |
+| `api_key` | string | - | Chave API do Tavily |
+| `base_url` | string | - | URL base personalizada do Tavily |
+| `max_results` | int | 0 | Número máximo de resultados (0 = padrão) |
+
+### SearXNG
+
+| Config | Tipo | Padrão | Descrição |
+|---------------|--------|--------------------------|--------------------------------|
+| `enabled` | bool | false | Habilitar pesquisa SearXNG |
+| `base_url` | string | `http://localhost:8888` | URL da instância SearXNG |
+| `max_results` | int | 5 | Número máximo de resultados |
+
+### GLM Search
+
+| Config | Tipo | Padrão | Descrição |
+|-----------------|--------|------------------------------------------------------|----------------------------|
+| `enabled` | bool | false | Habilitar GLM Search |
+| `api_key` | string | - | Chave API GLM |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL da API GLM Search |
+| `search_engine` | string | `search_std` | Tipo de motor de busca |
+| `max_results` | int | 5 | Número máximo de resultados |
## Ferramenta Exec
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index d0160050d..0528fe714 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -55,6 +55,31 @@ General settings for fetching and processing webpage content.
| `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results |
+### Baidu Search
+
+Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
+
+| Config | Type | Default | Description |
+|---------------|--------|------------------------------------------------------------------|---------------------------|
+| `enabled` | bool | false | Enable Baidu Search |
+| `api_key` | string | - | Qianfan API key |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
+| `max_results` | int | 10 | Maximum number of results |
+
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
+
### Perplexity
| Config | Type | Default | Description |
diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md
index 3680fed69..d907e5e91 100644
--- a/docs/vi/chat-apps.md
+++ b/docs/vi/chat-apps.md
@@ -13,6 +13,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
| **Telegram** | ⭐ Dễ | Khuyến nghị, chuyển giọng nói thành văn bản, long polling (không cần IP công khai) | [Tài liệu](../channels/telegram/README.vi.md) |
| **Discord** | ⭐ Dễ | Socket Mode, hỗ trợ nhóm/DM, hệ sinh thái bot phong phú | [Tài liệu](../channels/discord/README.vi.md) |
| **WhatsApp** | ⭐ Dễ | Bản địa (quét QR) hoặc Bridge URL | [Tài liệu](#whatsapp) |
+| **Weixin** | ⭐ Dễ | Quét QR gốc (API Tencent iLink) | [Tài liệu](#weixin) |
| **Slack** | ⭐ Dễ | **Socket Mode** (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/slack/README.vi.md) |
| **Matrix** | ⭐⭐ Trung bình | Giao thức liên kết, hỗ trợ tự lưu trữ | [Tài liệu](../channels/matrix/README.vi.md) |
| **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) |
@@ -20,11 +21,12 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
| **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) |
| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/README.vi.md) |
| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
-| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | - |
+| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) |
| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
+
Telegram (Khuyến nghị)
@@ -65,6 +67,7 @@ Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫ
+
Discord
@@ -138,6 +141,7 @@ picoclaw gateway
+
WhatsApp (native qua whatsmeow)
@@ -165,6 +169,43 @@ Nếu `session_store_path` trống, phiên được lưu tại `/what
+
+
+Weixin (WeChat Cá nhân)
+
+PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn thông qua API chính thức Tencent iLink.
+
+**1. Đăng nhập**
+
+Chạy luồng đăng nhập QR tương tác:
+```bash
+picoclaw onboard weixin
+```
+Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình.
+
+**2. Cấu hình**
+
+(Tùy chọn) Thêm ID người dùng WeChat vào `allow_from` để giới hạn ai có thể nhắn tin với bot:
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**3. Chạy**
+```bash
+picoclaw gateway
+```
+
+
+
+
QQ
@@ -206,6 +247,7 @@ Nếu bạn muốn tạo bot thủ công:
+
DingTalk
@@ -240,6 +282,7 @@ picoclaw gateway
+
MaixCam
@@ -262,6 +305,7 @@ picoclaw gateway
+
Matrix
@@ -296,6 +340,7 @@ picoclaw gateway
+
LINE
@@ -344,6 +389,7 @@ picoclaw gateway
+
WeCom (企业微信)
@@ -458,6 +504,7 @@ picoclaw gateway
+
Feishu (Lark)
@@ -499,6 +546,7 @@ Mở Feishu, tìm tên bot của bạn và bắt đầu trò chuyện. Bạn cũ
+
Slack
@@ -532,6 +580,7 @@ picoclaw gateway
+
IRC
@@ -565,6 +614,7 @@ Bot sẽ kết nối đến máy chủ IRC và tham gia các kênh đã chỉ đ
+
OneBot (QQ qua giao thức OneBot)
diff --git a/docs/vi/configuration.md b/docs/vi/configuration.md
index a21929359..fecadc6ff 100644
--- a/docs/vi/configuration.md
+++ b/docs/vi/configuration.md
@@ -216,4 +216,149 @@ Cho tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `
```markdown
# Tác Vụ Định Kỳ
+
+## Tác Vụ Nhanh (trả lời trực tiếp)
+
+- Báo giờ hiện tại
+
+## Tác Vụ Dài (dùng spawn cho bất đồng bộ)
+
+- Tìm kiếm tin tức AI trên web và tóm tắt
+- Kiểm tra email và báo cáo tin nhắn quan trọng
```
+
+**Hành vi chính:**
+
+| Tính năng | Mô tả |
+| ---------------- | ------------------------------------------------------------------ |
+| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
+| **Ngữ cảnh độc lập** | Subagent có ngữ cảnh riêng, không có lịch sử phiên |
+| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua message tool |
+| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
+
+#### Luồng Giao Tiếp Của Subagent
+
+```
+Heartbeat kích hoạt
+ ↓
+Agent đọc HEARTBEAT.md
+ ↓
+Tác vụ dài: spawn subagent
+ ↓ ↓
+Tiếp tục tác vụ tiếp theo Subagent hoạt động độc lập
+ ↓ ↓
+Hoàn thành tất cả tác vụ Subagent dùng công cụ "message"
+ ↓ ↓
+Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
+```
+
+**Cấu hình:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+| ---------- | -------- | -------------------------------------- |
+| `enabled` | `true` | Bật/tắt heartbeat |
+| `interval` | `30` | Khoảng thời gian kiểm tra tính bằng phút (tối thiểu: 5) |
+
+**Biến môi trường:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
+
+### Providers
+
+> [!NOTE]
+> Groq cung cấp chuyển đổi giọng nói thành văn bản miễn phí qua Whisper. Nếu được cấu hình, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển đổi ở cấp độ agent.
+
+| Provider | Mục đích | Lấy API Key |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM (Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM (khuyến nghị, truy cập tất cả mô hình) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **Chuyển đổi giọng nói** (Whisper)| [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid trực tiếp) | [vivgrid.com](https://vivgrid.com) |
+
+### Cấu Hình Mô Hình (model_list)
+
+> **Tính năng mới:** PicoClaw hiện sử dụng cách tiếp cận **lấy mô hình làm trung tâm**. Chỉ cần chỉ định định dạng `vendor/model` (ví dụ: `zhipu/glm-4.7`) để thêm provider mới — **không cần thay đổi code!**
+
+#### Tất Cả Vendor Được Hỗ Trợ
+
+| Vendor | Tiền tố `model` | API Base mặc định | Giao thức | API Key |
+| ----------------------- | --------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy](https://openrouter.ai/keys) |
+| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | Chỉ OAuth |
+
+#### Cân Bằng Tải
+
+Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự động round-robin:
+
+```json
+{
+ "model_list": [
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" },
+ { "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" }
+ ]
+}
+```
+
+#### Di Chuyển Từ Cấu Hình `providers` Cũ
+
+Cấu hình `providers` cũ đã **bị deprecated** nhưng vẫn được hỗ trợ. Xem [docs/migration/model-list-migration.md](../migration/model-list-migration.md).
+
+### Kiến Trúc Provider
+
+PicoClaw định tuyến provider theo họ giao thức:
+
+- **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác.
+- **Anthropic**: Hành vi API Claude gốc.
+- **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI.
+
+### Tác Vụ Đã Lên Lịch / Nhắc Nhở
+
+PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`.
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi động lại tại `~/.picoclaw/workspace/cron/`.
+
+### Chủ Đề Nâng Cao
+
+| Chủ đề | Mô tả |
+| ------ | ----- |
+| [Hệ Thống Hook](../hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook |
+| [Steering](../steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy |
+| [SubTurn](../subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời |
+| [Quản Lý Ngữ Cảnh](../agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén |
diff --git a/docs/vi/tools_configuration.md b/docs/vi/tools_configuration.md
index 76a336186..55e7699eb 100644
--- a/docs/vi/tools_configuration.md
+++ b/docs/vi/tools_configuration.md
@@ -41,14 +41,6 @@ Cài đặt chung để tải và xử lý nội dung trang web.
| `fetch_limit_bytes` | int | 10485760 | Kích thước tối đa của payload trang web cần tải, tính bằng byte (mặc định là 10MB). |
| `format` | string | "plaintext" | Định dạng đầu ra của nội dung đã tải. Tùy chọn: `plaintext` hoặc `markdown` (khuyến nghị). |
-### Brave
-
-| Cấu hình | Kiểu | Mặc định | Mô tả |
-|----------------|--------|----------|----------------------------|
-| `enabled` | bool | false | Bật tìm kiếm Brave |
-| `api_key` | string | - | Khóa API Brave Search |
-| `max_results` | int | 5 | Số kết quả tối đa |
-
### DuckDuckGo
| Cấu hình | Kiểu | Mặc định | Mô tả |
@@ -56,13 +48,73 @@ Cài đặt chung để tải và xử lý nội dung trang web.
| `enabled` | bool | true | Bật tìm kiếm DuckDuckGo |
| `max_results` | int | 5 | Số kết quả tối đa |
+### Baidu Search
+
+| Cấu hình | Kiểu | Mặc định | Mô tả |
+|----------------|--------|-----------------------------------------------------------------|------------------------------------|
+| `enabled` | bool | false | Bật tìm kiếm Baidu |
+| `api_key` | string | - | Khóa API Qianfan |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | URL API Baidu Search |
+| `max_results` | int | 10 | Số kết quả tối đa |
+
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
+
### Perplexity
| Cấu hình | Kiểu | Mặc định | Mô tả |
|----------------|--------|----------|-------------------------------|
-| `enabled` | bool | false | Bật tìm kiếm Perplexity |
-| `api_key` | string | - | Khóa API Perplexity |
-| `max_results` | int | 5 | Số kết quả tối đa |
+| `enabled` | bool | false | Bật tìm kiếm Perplexity |
+| `api_key` | string | - | Khóa API Perplexity |
+| `api_keys` | string[] | - | Nhiều khóa API Perplexity để xoay vòng (ưu tiên hơn `api_key`) |
+| `max_results` | int | 5 | Số kết quả tối đa |
+
+### Brave
+
+| Cấu hình | Kiểu | Mặc định | Mô tả |
+|----------------|--------|----------|----------------------------|
+| `enabled` | bool | false | Bật tìm kiếm Brave |
+| `api_key` | string | - | Khóa API Brave Search |
+| `api_keys` | string[] | - | Nhiều khóa API Brave Search để xoay vòng (ưu tiên hơn `api_key`) |
+| `max_results` | int | 5 | Số kết quả tối đa |
+
+### Tavily
+
+| Cấu hình | Kiểu | Mặc định | Mô tả |
+|----------------|--------|----------|------------------------------------|
+| `enabled` | bool | false | Bật tìm kiếm Tavily |
+| `api_key` | string | - | Khóa API Tavily |
+| `base_url` | string | - | URL cơ sở Tavily tùy chỉnh |
+| `max_results` | int | 0 | Số kết quả tối đa (0 = mặc định) |
+
+### SearXNG
+
+| Cấu hình | Kiểu | Mặc định | Mô tả |
+|----------------|--------|--------------------------|----------------------------|
+| `enabled` | bool | false | Bật tìm kiếm SearXNG |
+| `base_url` | string | `http://localhost:8888` | URL phiên bản SearXNG |
+| `max_results` | int | 5 | Số kết quả tối đa |
+
+### GLM Search
+
+| Cấu hình | Kiểu | Mặc định | Mô tả |
+|------------------|--------|------------------------------------------------------|----------------------------|
+| `enabled` | bool | false | Bật GLM Search |
+| `api_key` | string | - | Khóa API GLM |
+| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | URL API GLM Search |
+| `search_engine` | string | `search_std` | Loại công cụ tìm kiếm |
+| `max_results` | int | 5 | Số kết quả tối đa |
## Công cụ Exec
diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md
index 2d6e55c3d..aeba7d460 100644
--- a/docs/zh/chat-apps.md
+++ b/docs/zh/chat-apps.md
@@ -15,7 +15,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](../channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](../channels/discord/README.zh.md) |
| **WhatsApp** | ⭐ 简单 | 原生 (QR 扫码) 或 Bridge URL | [查看文档](#whatsapp) |
-| **Weixin** | ⭐ 简单 | 原生扫码登录 (腾讯 iLink API) | [查看文档](../channels/weixin/README.zh.md) |
+| **微信 (Weixin)** | ⭐ 简单 | 原生扫码(腾讯 iLink API) | [查看文档](#weixin) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](../channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](../channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](../channels/qq/README.zh.md) |
@@ -23,13 +23,14 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **LINE** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](../channels/line/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](../channels/wecom/wecom_bot/README.zh.md) / [App 文档](../channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](../channels/wecom/wecom_aibot/README.zh.md) |
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
-| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | - |
+| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
---
+
Telegram (推荐)
@@ -63,13 +64,21 @@ picoclaw gateway
**4. Telegram 命令菜单(启动时自动注册)**
-PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
+PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
+你也可以直接在 Telegram 中管理已安装技能:
+
+- `/list skills`
+- `/use `
+- `/use `,然后在下一条消息里发送真正的请求
+- `/use clear`
+
+
Discord
@@ -144,6 +153,7 @@ picoclaw gateway
+
WhatsApp (原生 whatsmeow)
@@ -171,27 +181,30 @@ PicoClaw 支持两种 WhatsApp 连接方式:
+
-Weixin (微信个人号)
+微信 (Weixin)
-PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。
+PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。
**1. 登录**
+
运行交互式扫码登录流程:
```bash
picoclaw onboard weixin
```
-在终端扫描打印出的二维码。登录成功后,Token 将自动保存到您的配置文件中。
+用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。
**2. 配置**
-(可选)更新 `allow_from` 填写微信 User ID,以限制哪些用户可以给机器人发消息:
+
+(可选)在 `allow_from` 中填入你的微信用户 ID,限制可以与机器人对话的用户:
```json
{
"channels": {
"weixin": {
"enabled": true,
- "token": "你的_TOKEN",
- "allow_from": ["你的_USER_ID"]
+ "token": "YOUR_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -204,6 +217,7 @@ picoclaw gateway
+
Matrix
@@ -238,6 +252,7 @@ picoclaw gateway
+
QQ
@@ -279,6 +294,7 @@ QQ 开放平台提供了一键创建 OpenClaw 兼容机器人的页面:
+
Slack
@@ -312,6 +328,7 @@ picoclaw gateway
+
IRC
@@ -345,6 +362,7 @@ Bot 将连接到 IRC 服务器并加入指定的频道。
+
钉钉 (DingTalk)
@@ -379,6 +397,7 @@ picoclaw gateway
+
LINE
@@ -427,6 +446,7 @@ picoclaw gateway
+
飞书 (Feishu)
@@ -468,6 +488,7 @@ picoclaw gateway
+
企业微信 (WeCom)
@@ -582,6 +603,7 @@ picoclaw gateway
+
OneBot(通过 OneBot 协议连接 QQ)
@@ -620,6 +642,7 @@ picoclaw gateway
+
MaixCam
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index 68fb1fd1a..695e22829 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -65,6 +65,24 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
+### 在聊天频道中使用技能
+
+技能安装完成后,可以直接在聊天频道里查看并显式启用它们:
+
+- `/list skills`:显示当前 Agent 可用的已安装技能名称。
+- `/use `:只对当前这一条请求强制使用指定技能。
+- `/use `:为同一会话中的下一条消息预先启用该技能。
+- `/use clear`:取消通过 `/use ` 设置的待应用技能。
+
+示例:
+
+```text
+/list skills
+/use git explain how to squash the last 3 commits
+/use italiapersonalfinance
+dammi le ultime news
+```
+
### 统一命令执行策略
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
@@ -256,3 +274,356 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
+
+#### 子 Agent 通信流程
+
+```
+心跳触发
+ ↓
+Agent 读取 HEARTBEAT.md
+ ↓
+遇到耗时任务:spawn 子 Agent
+ ↓ ↓
+继续处理下一个任务 子 Agent 独立运行
+ ↓ ↓
+所有任务完成 子 Agent 使用 "message" 工具
+ ↓ ↓
+回复 HEARTBEAT_OK 用户直接收到结果
+```
+
+子 Agent 拥有工具访问权限(message、web_search 等),可以独立与用户通信,无需经过主 Agent。
+
+### Providers(模型提供商)
+
+> [!NOTE]
+> Groq 通过 Whisper 提供免费语音转录。配置后,任意渠道的语音消息都会在 Agent 层自动转录为文字。
+
+| 提供商 | 用途 | 获取 API Key |
+| ------------ | --------------------------------------- | ------------------------------------------------------------ |
+| `gemini` | LLM(Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM(智谱直连) | [bigmodel.cn](https://bigmodel.cn) |
+| `volcengine` | LLM(火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| `openrouter` | LLM(推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` | LLM(Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` | LLM(GPT 直连) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM(DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM(通义千问直连) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **语音转录**(Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM(Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM(Vivgrid 直连) | [vivgrid.com](https://vivgrid.com) |
+
+### 模型配置 (model_list)
+
+> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!**
+
+这一设计同时支持**多 Agent**场景,灵活选择提供商:
+
+- **不同 Agent 使用不同提供商**:每个 Agent 可以使用独立的 LLM 提供商
+- **模型降级**:配置主模型和备用模型,提升可用性
+- **负载均衡**:将请求分发到多个端点
+- **集中管理**:在一处管理所有提供商配置
+
+#### 所有支持的厂商
+
+| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key |
+| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
+| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
+| **火山引擎 (豆包)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
+| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
+| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
+| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
+| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
+
+#### 基础配置
+
+```json
+{
+ "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": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.4"
+ }
+ }
+}
+```
+
+#### 各厂商配置示例
+
+
+OpenAI
+
+```json
+{
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+火山引擎(豆包)
+
+```json
+{
+ "model_name": "ark-code-latest",
+ "model": "volcengine/ark-code-latest",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+智谱 AI (GLM)
+
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+
+
+
+DeepSeek
+
+```json
+{
+ "model_name": "deepseek-chat",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-..."
+}
+```
+
+
+
+
+Anthropic
+
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+}
+```
+
+> 运行 `picoclaw auth login --provider anthropic` 粘贴 API Token。
+
+如需直连 Anthropic 原生接口(不兼容 OpenAI 格式的端点):
+
+```json
+{
+ "model_name": "claude-opus-4-6",
+ "model": "anthropic-messages/claude-opus-4-6",
+ "api_key": "sk-ant-your-key",
+ "api_base": "https://api.anthropic.com"
+}
+```
+
+> 当端点不支持 OpenAI 兼容格式(`/v1/chat/completions`),需要 Anthropic 原生 `/v1/messages` 时使用 `anthropic-messages`。
+
+
+
+
+Ollama(本地)
+
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+
+
+
+自定义代理 / LiteLLM
+
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`。
+
+
+
+#### 负载均衡
+
+为同一模型名称配置多个端点,PicoClaw 会自动轮询:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### 从旧版 `providers` 配置迁移
+
+旧版 `providers` 配置**已废弃**,但仍向后兼容。完整迁移指南见 [docs/migration/model-list-migration.md](../migration/model-list-migration.md)。
+
+### Provider 架构
+
+PicoClaw 按协议族路由提供商:
+
+- **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。
+- **Anthropic**:Claude 原生 API 行为。
+- **Codex/OAuth**:OpenAI OAuth/Token 认证路由。
+
+这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。
+
+
+智谱(旧版 providers 格式)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+
+
+
+完整配置示例
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "session": {
+ "dm_scope": "per-channel-peer",
+ "backlog_limit": 20
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ }
+ },
+ "tools": {
+ "web": {
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### 定时任务 / 提醒
+
+PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。
+
+```json
+{
+ "tools": {
+ "cron": {
+ "enabled": true,
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+```
+
+定时任务在重启后持久保存,存储于 `~/.picoclaw/workspace/cron/`。
+
+### 进阶主题
+
+| 主题 | 说明 |
+| ---- | ---- |
+| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook |
+| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 |
+| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 |
+| [上下文管理](../agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 |
diff --git a/docs/zh/providers.md b/docs/zh/providers.md
index 9092e7dfe..e7b323ebf 100644
--- a/docs/zh/providers.md
+++ b/docs/zh/providers.md
@@ -5,7 +5,7 @@
### 提供商 (Providers)
> [!NOTE]
-> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
+> 语音转录现在可以通过 `voice.model_name` 指定的多模态模型完成;如果未配置语音模型,Groq Whisper 仍可作为回退方案。
| 提供商 | 用途 | 获取 API Key |
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
@@ -99,6 +99,33 @@
}
```
+#### 语音转录
+
+你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
+
+如果没有配置 `voice.model_name`,且存在 Groq API Key,PicoClaw 会继续回退到 Groq 转录。
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "voice-gemini",
+ "model": "gemini/gemini-2.5-flash",
+ "api_key": "your-gemini-key"
+ }
+ ],
+ "voice": {
+ "model_name": "voice-gemini",
+ "echo_transcription": false
+ },
+ "providers": {
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ }
+}
+```
+
#### 各厂商配置示例
**OpenAI**
@@ -342,6 +369,10 @@ picoclaw agent -m "你好"
"api_key": "gsk_xxx"
}
},
+ "voice": {
+ "model_name": "voice-gemini",
+ "echo_transcription": false
+ },
"channels": {
"telegram": {
"enabled": true,
diff --git a/docs/zh/tools_configuration.md b/docs/zh/tools_configuration.md
index f13448952..a3816a35a 100644
--- a/docs/zh/tools_configuration.md
+++ b/docs/zh/tools_configuration.md
@@ -41,30 +41,30 @@ Web 工具用于网页搜索和抓取。
| `fetch_limit_bytes` | int | 10485760 | 抓取网页负载的最大大小,单位为字节(默认 10MB)。 |
| `format` | string | "plaintext" | 抓取内容的输出格式。选项:`plaintext` 或 `markdown`(推荐)。 |
-### Brave
+### 百度搜索
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|----------|--------|------------------------------------------------|
-| `enabled` | bool | false | 启用 Brave 搜索 |
-| `api_key` | string | - | Brave Search API 密钥 |
-| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
-| `max_results` | int | 5 | 最大结果数 |
+使用[千帆 AI 搜索 API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5),国内访问稳定,中文搜索效果好。
-### DuckDuckGo
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|--------|----------------------------------------------------------------|-----------------------|
+| `enabled` | bool | false | 启用百度搜索 |
+| `api_key` | string | - | 千帆 API 密钥 |
+| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | 百度搜索 API URL |
+| `max_results` | int | 10 | 最大结果数 |
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|------|--------|-----------------------|
-| `enabled` | bool | true | 启用 DuckDuckGo 搜索 |
-| `max_results` | int | 5 | 最大结果数 |
-
-### Perplexity
-
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|----------|--------|------------------------------------------------|
-| `enabled` | bool | false | 启用 Perplexity 搜索 |
-| `api_key` | string | - | Perplexity API 密钥 |
-| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
-| `max_results` | int | 5 | 最大结果数 |
+```json
+{
+ "tools": {
+ "web": {
+ "baidu_search": {
+ "enabled": true,
+ "api_key": "YOUR_BAIDU_QIANFAN_API_KEY",
+ "max_results": 10
+ }
+ }
+ }
+}
+```
### Tavily
@@ -75,14 +75,6 @@ Web 工具用于网页搜索和抓取。
| `base_url` | string | - | 自定义 Tavily API 基础 URL |
| `max_results` | int | 0 | 最大结果数(0 = 默认) |
-### SearXNG
-
-| 配置项 | 类型 | 默认值 | 描述 |
-|---------------|--------|--------------------------|-----------------------|
-| `enabled` | bool | false | 启用 SearXNG 搜索 |
-| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL |
-| `max_results` | int | 5 | 最大结果数 |
-
### GLM Search
| 配置项 | 类型 | 默认值 | 描述 |
@@ -93,6 +85,45 @@ Web 工具用于网页搜索和抓取。
| `search_engine` | string | `search_std` | 搜索引擎类型 |
| `max_results` | int | 5 | 最大结果数 |
+### DuckDuckGo
+
+> ⚠️ 国内访问困难,建议搭配代理使用。
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|------|--------|-----------------------|
+| `enabled` | bool | true | 启用 DuckDuckGo 搜索 |
+| `max_results` | int | 5 | 最大结果数 |
+
+### Perplexity
+
+> ⚠️ 国内访问困难,建议搭配代理使用。
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|----------|--------|------------------------------------------------|
+| `enabled` | bool | false | 启用 Perplexity 搜索 |
+| `api_key` | string | - | Perplexity API 密钥 |
+| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
+| `max_results` | int | 5 | 最大结果数 |
+
+### Brave
+
+> ⚠️ 国内访问困难,建议搭配代理使用。
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|----------|--------|------------------------------------------------|
+| `enabled` | bool | false | 启用 Brave 搜索 |
+| `api_key` | string | - | Brave Search API 密钥 |
+| `api_keys` | string[] | - | 多个 API 密钥轮换(优先于 `api_key`) |
+| `max_results` | int | 5 | 最大结果数 |
+
+### SearXNG
+
+| 配置项 | 类型 | 默认值 | 描述 |
+|---------------|--------|--------------------------|-----------------------|
+| `enabled` | bool | false | 启用 SearXNG 搜索 |
+| `base_url` | string | `http://localhost:8888` | SearXNG 实例 URL |
+| `max_results` | int | 5 | 最大结果数 |
+
### 其他 Web 设置
| 配置项 | 类型 | 默认值 | 描述 |
diff --git a/go.mod b/go.mod
index cfc930d37..e4b6f37fd 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,8 @@ module github.com/sipeed/picoclaw
go 1.25.8
require (
- github.com/BurntSushi/toml v1.6.0
fyne.io/systray v1.12.0
+ github.com/BurntSushi/toml v1.6.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/bwmarrin/discordgo v0.29.0
@@ -96,5 +96,5 @@ require (
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
+ golang.org/x/sys v0.42.0
)
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index d905674f3..12e3cdd4d 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@@ -59,7 +60,7 @@ func getGlobalConfigDir() string {
if err != nil {
return ""
}
- return filepath.Join(home, ".picoclaw")
+ return filepath.Join(home, pkg.DefaultPicoClawHome)
}
func NewContextBuilder(workspace string) *ContextBuilder {
@@ -677,8 +678,21 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
// be followed by tool messages responding to each 'tool_call_id'."
final := make([]providers.Message, 0, len(sanitized))
+ seenToolCallID := make(map[string]bool)
for i := 0; i < len(sanitized); i++ {
msg := sanitized[i]
+
+ // Deduplicate tool results by ToolCallID
+ if msg.Role == "tool" && msg.ToolCallID != "" {
+ if seenToolCallID[msg.ToolCallID] {
+ logger.DebugCF("agent", "Dropping duplicate tool result", map[string]any{
+ "tool_call_id": msg.ToolCallID,
+ })
+ continue
+ }
+ seenToolCallID[msg.ToolCallID] = true
+ }
+
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
// Collect expected tool_call IDs
expected := make(map[string]bool, len(msg.ToolCalls))
diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go
index 5756ed911..0d7948eef 100644
--- a/pkg/agent/context_test.go
+++ b/pkg/agent/context_test.go
@@ -188,6 +188,31 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
assertRoles(t, result, "user", "assistant", "user", "assistant")
}
+func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) {
+ history := []providers.Message{
+ msg("user", "do something"),
+ assistantWithTools("A", "B"),
+ toolResult("A"),
+ toolResult("B"),
+ toolResult("A"), // duplicate
+ toolResult("B"), // duplicate
+ msg("assistant", "done"),
+ }
+
+ result := sanitizeHistoryForProvider(history)
+ if len(result) != 5 {
+ t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
+ }
+ assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
+ // Verify the kept tool results have the correct IDs
+ if result[2].ToolCallID != "A" {
+ t.Errorf("expected tool result A, got %q", result[2].ToolCallID)
+ }
+ if result[3].ToolCallID != "B" {
+ t.Errorf("expected tool result B, got %q", result[3].ToolCallID)
+ }
+}
+
func roles(msgs []providers.Message) []string {
r := make([]string, len(msgs))
for i, m := range msgs {
diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go
index 9acc6ddd8..19a1ea9eb 100644
--- a/pkg/agent/eventbus_test.go
+++ b/pkg/agent/eventbus_test.go
@@ -109,7 +109,7 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -228,7 +228,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -353,7 +353,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -443,7 +443,7 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
ContextWindow: 8000,
@@ -500,7 +500,7 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go
index a9d8f27c5..85d8f5c11 100644
--- a/pkg/agent/hook_mount_test.go
+++ b/pkg/agent/hook_mount_test.go
@@ -47,7 +47,7 @@ func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks co
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go
index e6471e9cc..49e1b1784 100644
--- a/pkg/agent/hooks_test.go
+++ b/pkg/agent/hooks_test.go
@@ -28,7 +28,7 @@ func newHookTestLoop(
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index b3318ad1f..e073cb929 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -22,7 +22,7 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
@@ -54,7 +54,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
@@ -83,7 +83,7 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
@@ -137,10 +137,10 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: tt.aliasName,
+ ModelName: tt.aliasName,
},
},
- ModelList: []config.ModelConfig{
+ ModelList: []*config.ModelConfig{
{
ModelName: tt.aliasName,
Model: tt.modelName,
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index c202eff17..f1fc9a2be 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -163,30 +163,37 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("web") {
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
- BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
- BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
- BraveEnabled: cfg.Tools.Web.Brave.Enabled,
- TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
+ BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()),
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKeys: config.MergeAPIKeys(
+ cfg.Tools.Web.Tavily.APIKey(),
+ cfg.Tools.Web.Tavily.APIKeys(),
+ ),
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKeys: config.MergeAPIKeys(
- cfg.Tools.Web.Perplexity.APIKey,
- cfg.Tools.Web.Perplexity.APIKeys,
+ cfg.Tools.Web.Perplexity.APIKey(),
+ cfg.Tools.Web.Perplexity.APIKeys(),
),
- PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
- PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
- SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
- SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
- SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
- GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
- GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
- GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
- GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
- GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
- Proxy: cfg.Tools.Web.Proxy,
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
+ SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
+ SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
+ GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(),
+ GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
+ GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
+ GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
+ GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
+ BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey(),
+ BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
+ BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
+ BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
+ Proxy: cfg.Tools.Web.Proxy,
})
if err != nil {
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
@@ -248,9 +255,20 @@ func registerSharedTools(
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
if skills_enabled && (find_skills_enable || install_skills_enable) {
+ clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
- ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ ClawHub: skills.ClawHubConfig{
+ Enabled: clawHubConfig.Enabled,
+ BaseURL: clawHubConfig.BaseURL,
+ AuthToken: clawHubConfig.AuthToken(),
+ SearchPath: clawHubConfig.SearchPath,
+ SkillsPath: clawHubConfig.SkillsPath,
+ DownloadPath: clawHubConfig.DownloadPath,
+ Timeout: clawHubConfig.Timeout,
+ MaxZipSize: clawHubConfig.MaxZipSize,
+ MaxResponseSize: clawHubConfig.MaxResponseSize,
+ },
})
if find_skills_enable {
@@ -1668,7 +1686,6 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
activeCandidates, activeModel := al.selectCandidates(ts.agent, ts.userMessage, messages)
pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...)
var finalContent string
- const handledToolResponseSummary = "Requested output delivered via tool attachment."
turnLoop:
for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool {
@@ -2010,8 +2027,7 @@ turnLoop:
newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey)
messages = ts.agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "",
- nil, ts.channel, ts.chatID,
- "", "", // Empty SenderID and SenderDisplayName for retry
+ nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
)
callMessages = messages
@@ -3294,6 +3310,9 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return nil
},
}
+ if agent != nil && agent.ContextBuilder != nil {
+ rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
+ }
rt.ReloadConfig = func() error {
if al.reloadFunc == nil {
return fmt.Errorf("reload not configured")
@@ -3354,6 +3373,146 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return rt
}
+func activeSkillNames(agent *AgentInstance, opts processOptions) []string {
+ var out []string
+ seen := make(map[string]struct{})
+
+ appendNames := func(names []string) {
+ for _, name := range names {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ if _, exists := seen[name]; exists {
+ continue
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ }
+
+ if agent != nil {
+ appendNames(agent.SkillsFilter)
+ }
+ appendNames(opts.ForcedSkills)
+
+ return out
+}
+
+func (al *AgentLoop) applyExplicitSkillCommand(
+ raw string,
+ agent *AgentInstance,
+ opts *processOptions,
+) (matched bool, handled bool, reply string) {
+ commandName, ok := commands.CommandName(raw)
+ if !ok || commandName != "use" {
+ return false, false, ""
+ }
+
+ if agent == nil || agent.ContextBuilder == nil {
+ return true, true, commandsUnavailableSkillMessage()
+ }
+
+ fields := strings.Fields(strings.TrimSpace(raw))
+ if len(fields) < 2 {
+ return true, true, buildUseCommandHelp(agent)
+ }
+
+ if strings.EqualFold(fields[1], "clear") || strings.EqualFold(fields[1], "off") {
+ al.clearPendingSkills(opts.SessionKey)
+ return true, true, "Cleared pending skill override."
+ }
+
+ canonicalSkill, ok := agent.ContextBuilder.ResolveSkillName(fields[1])
+ if !ok {
+ return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", fields[1])
+ }
+
+ if len(fields) == 2 {
+ al.setPendingSkills(opts.SessionKey, []string{canonicalSkill})
+ return true, true, fmt.Sprintf(
+ "Skill %q is armed for your next message.\nSend your next request normally, or use /use clear to cancel.",
+ canonicalSkill,
+ )
+ }
+
+ message := strings.TrimSpace(strings.Join(fields[2:], " "))
+ if message == "" {
+ return true, true, buildUseCommandHelp(agent)
+ }
+
+ opts.UserMessage = message
+ opts.ForcedSkills = append(opts.ForcedSkills, canonicalSkill)
+ return true, false, ""
+}
+
+func commandsUnavailableSkillMessage() string {
+ return "Skill selection is unavailable in the current context."
+}
+
+func buildUseCommandHelp(agent *AgentInstance) string {
+ if agent == nil || agent.ContextBuilder == nil {
+ return "Usage: /use [message]"
+ }
+
+ names := agent.ContextBuilder.ListSkillNames()
+ if len(names) == 0 {
+ return "Usage: /use [message]\nNo installed skills found."
+ }
+
+ return fmt.Sprintf(
+ "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.",
+ strings.Join(names, "\n- "),
+ )
+}
+
+func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" || len(skillNames) == 0 {
+ return
+ }
+
+ filtered := make([]string, 0, len(skillNames))
+ for _, name := range skillNames {
+ name = strings.TrimSpace(name)
+ if name != "" {
+ filtered = append(filtered, name)
+ }
+ }
+ if len(filtered) == 0 {
+ return
+ }
+
+ al.pendingSkills.Store(sessionKey, filtered)
+}
+
+func (al *AgentLoop) takePendingSkills(sessionKey string) []string {
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return nil
+ }
+
+ value, ok := al.pendingSkills.LoadAndDelete(sessionKey)
+ if !ok {
+ return nil
+ }
+
+ skills, ok := value.([]string)
+ if !ok {
+ return nil
+ }
+
+ return append([]string(nil), skills...)
+}
+
+func (al *AgentLoop) clearPendingSkills(sessionKey string) {
+ sessionKey = strings.TrimSpace(sessionKey)
+ if sessionKey == "" {
+ return
+ }
+ al.pendingSkills.Delete(sessionKey)
+}
+
func commandsUnavailableSkillMessage() string {
return "Skill commands are unavailable in the current context."
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index b7442e2fc..9fd737e12 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -67,7 +67,7 @@ func newTestAgentLoop(
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -90,7 +90,7 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -132,6 +132,163 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
}
}
+func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
+ tmpDir := t.TempDir()
+ skillDir := filepath.Join(tmpDir, "skills", "shell")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("mkdir skill dir: %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(skillDir, "SKILL.md"),
+ []byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("write skill file: %v", err)
+ }
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+ msgBus := bus.NewMessageBus()
+ provider := &recordingProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ response, err := al.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "telegram:123",
+ ChatID: "chat-1",
+ Content: "/use shell explain how to list files",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() error = %v", err)
+ }
+ if response != "Mock response" {
+ t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
+ }
+ if len(provider.lastMessages) == 0 {
+ t.Fatal("provider did not receive any messages")
+ }
+
+ systemPrompt := provider.lastMessages[0].Content
+ if !strings.Contains(systemPrompt, "# Active Skills") {
+ t.Fatalf("system prompt missing active skills section:\n%s", systemPrompt)
+ }
+ if !strings.Contains(systemPrompt, "### Skill: shell") {
+ t.Fatalf("system prompt missing requested skill content:\n%s", systemPrompt)
+ }
+
+ lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
+ if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
+ t.Fatalf("last provider message = %+v, want rewritten user message", lastMessage)
+ }
+}
+
+func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
+ tmpDir := t.TempDir()
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+ msgBus := bus.NewMessageBus()
+ provider := &recordingProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+ agent := al.GetRegistry().GetDefaultAgent()
+
+ opts := processOptions{}
+ reply, handled := al.handleCommand(context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "telegram:123",
+ ChatID: "chat-1",
+ Content: "/use missing explain how to list files",
+ }, agent, &opts)
+ if !handled {
+ t.Fatal("expected /use with unknown skill to be handled")
+ }
+ if !strings.Contains(reply, "Unknown skill: missing") {
+ t.Fatalf("reply = %q, want unknown skill error", reply)
+ }
+}
+
+func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) {
+ tmpDir := t.TempDir()
+ skillDir := filepath.Join(tmpDir, "skills", "shell")
+ if err := os.MkdirAll(skillDir, 0o755); err != nil {
+ t.Fatalf("mkdir skill dir: %v", err)
+ }
+ if err := os.WriteFile(
+ filepath.Join(skillDir, "SKILL.md"),
+ []byte("# shell\n\nPrefer concise shell commands and explain them briefly."),
+ 0o644,
+ ); err != nil {
+ t.Fatalf("write skill file: %v", err)
+ }
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ ModelName: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+ msgBus := bus.NewMessageBus()
+ provider := &recordingProvider{}
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ response, err := al.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "telegram:123",
+ ChatID: "chat-1",
+ Content: "/use shell",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() arm error = %v", err)
+ }
+ if !strings.Contains(response, `Skill "shell" is armed for your next message.`) {
+ t.Fatalf("arm response = %q, want armed confirmation", response)
+ }
+
+ response, err = al.processMessage(context.Background(), bus.InboundMessage{
+ Channel: "telegram",
+ SenderID: "telegram:123",
+ ChatID: "chat-1",
+ Content: "explain how to list files",
+ })
+ if err != nil {
+ t.Fatalf("processMessage() follow-up error = %v", err)
+ }
+ if response != "Mock response" {
+ t.Fatalf("follow-up response = %q, want %q", response, "Mock response")
+ }
+ if len(provider.lastMessages) == 0 {
+ t.Fatal("provider did not receive any messages")
+ }
+
+ systemPrompt := provider.lastMessages[0].Content
+ if !strings.Contains(systemPrompt, "### Skill: shell") {
+ t.Fatalf("system prompt missing pending skill content:\n%s", systemPrompt)
+ }
+ lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
+ if lastMessage.Role != "user" || lastMessage.Content != "explain how to list files" {
+ t.Fatalf("last provider message = %+v, want unchanged follow-up user message", lastMessage)
+ }
+}
+
func TestApplyExplicitSkillCommand_ArmsSkillForNextMessage(t *testing.T) {
al, cfg, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
@@ -259,7 +416,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -295,7 +452,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -352,7 +509,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -540,7 +697,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = tmpDir
- cfg.Agents.Defaults.Model = "test-model"
+ cfg.Agents.Defaults.ModelName = "test-model"
cfg.Agents.Defaults.MaxTokens = 4096
cfg.Agents.Defaults.MaxToolIterations = 10
@@ -584,7 +741,7 @@ func TestAgentLoop_Stop(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -940,7 +1097,7 @@ func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -996,7 +1153,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1076,26 +1233,34 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
- Model: "local",
+ ModelName: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
- ModelList: []config.ModelConfig{
+ 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",
},
},
}
+ cfg.WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "local": {
+ APIKeys: []string{"test-key"},
+ },
+ "deepseek": {
+ APIKeys: []string{"test-key"},
+ },
+ },
+ })
msgBus := bus.NewMessageBus()
provider := &countingMockProvider{response: "LLM reply"}
@@ -1147,20 +1312,26 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
- Model: "local",
+ ModelName: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
- ModelList: []config.ModelConfig{
+ ModelList: []*config.ModelConfig{
{
ModelName: "local",
Model: "openai/local-model",
- APIKey: "test-key",
APIBase: "https://local.example.invalid/v1",
},
},
}
+ cfg.WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "local": {
+ APIKeys: []string{"test-key"},
+ },
+ },
+ })
msgBus := bus.NewMessageBus()
provider := &countingMockProvider{response: "LLM reply"}
@@ -1222,26 +1393,34 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Provider: "openai",
- Model: "local",
+ ModelName: "local",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
- ModelList: []config.ModelConfig{
+ 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,
},
},
}
+ cfg.WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "local": {
+ APIKeys: []string{"local-key"},
+ },
+ "deepseek": {
+ APIKeys: []string{"remote-key"},
+ },
+ },
+ })
msgBus := bus.NewMessageBus()
provider, _, err := providers.CreateProvider(cfg)
@@ -1328,7 +1507,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1370,7 +1549,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1441,7 +1620,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1521,7 +1700,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 3,
},
@@ -1552,7 +1731,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 1,
},
@@ -1609,7 +1788,7 @@ func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1661,7 +1840,7 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1731,7 +1910,7 @@ func TestHandleReasoning(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
index 518bb441f..b173ef967 100644
--- a/pkg/agent/registry_test.go
+++ b/pkg/agent/registry_test.go
@@ -29,7 +29,7 @@ func testCfg(agents []config.AgentConfig) *config.Config {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test-registry",
- Model: "gpt-4",
+ ModelName: "gpt-4",
MaxTokens: 8192,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go
index fe4863f05..75ba9861d 100644
--- a/pkg/agent/steering_test.go
+++ b/pkg/agent/steering_test.go
@@ -267,7 +267,7 @@ func TestAgentLoop_SteeringMode_ConfiguredFromConfig(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
SteeringMode: "all",
@@ -318,7 +318,7 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -351,7 +351,7 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -646,7 +646,7 @@ func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -751,7 +751,7 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -818,7 +818,7 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -942,7 +942,7 @@ func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1024,7 +1024,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1127,7 +1127,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1295,7 +1295,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -1454,7 +1454,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go
index bac786eb3..6a2ba835d 100644
--- a/pkg/agent/subturn_test.go
+++ b/pkg/agent/subturn_test.go
@@ -844,7 +844,7 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
- Model: "test-model",
+ ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
@@ -938,8 +938,8 @@ func TestGetActiveTurn(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
- Model: "gpt-4o-mini",
- Provider: "mock",
+ ModelName: "gpt-4o-mini",
+ Provider: "mock",
},
},
}
@@ -996,8 +996,8 @@ func TestGetActiveTurn_WithChildren(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
- Model: "gpt-4o-mini",
- Provider: "mock",
+ ModelName: "gpt-4o-mini",
+ Provider: "mock",
},
},
}
@@ -1077,8 +1077,8 @@ func TestInjectFollowUp(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
- Model: "gpt-4o-mini",
- Provider: "mock",
+ ModelName: "gpt-4o-mini",
+ Provider: "mock",
},
},
}
@@ -1106,8 +1106,8 @@ func TestAPIAliases(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
- Model: "gpt-4o-mini",
- Provider: "mock",
+ ModelName: "gpt-4o-mini",
+ Provider: "mock",
},
},
}
@@ -1145,8 +1145,8 @@ func TestInterruptHard_Alias(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
- Model: "gpt-4o-mini",
- Provider: "mock",
+ ModelName: "gpt-4o-mini",
+ Provider: "mock",
},
},
}
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index f7813ca57..8a878d553 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"time"
+ "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
@@ -44,7 +45,7 @@ func authFilePath() string {
return filepath.Join(home, "auth.json")
}
home, _ := os.UserHomeDir()
- return filepath.Join(home, ".picoclaw", "auth.json")
+ return filepath.Join(home, pkg.DefaultPicoClawHome, "auth.json")
}
func LoadStore() (*AuthStore, error) {
diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go
index c03122892..7ac2c073f 100644
--- a/pkg/channels/dingtalk/dingtalk.go
+++ b/pkg/channels/dingtalk/dingtalk.go
@@ -36,7 +36,7 @@ type DingTalkChannel struct {
// NewDingTalkChannel creates a new DingTalk channel instance
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
- if cfg.ClientID == "" || cfg.ClientSecret == "" {
+ if cfg.ClientID == "" || cfg.ClientSecret() == "" {
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
@@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
BaseChannel: base,
config: cfg,
clientID: cfg.ClientID,
- clientSecret: cfg.ClientSecret,
+ clientSecret: cfg.ClientSecret(),
}, nil
}
diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go
index 83a04907c..3b5b4f8bb 100644
--- a/pkg/channels/discord/discord.go
+++ b/pkg/channels/discord/discord.go
@@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
discordgo.LogDebug: logger.DEBUG,
}).Log
- session, err := discordgo.New("Bot " + cfg.Token)
+ session, err := discordgo.New("Bot " + cfg.Token())
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
}
@@ -396,8 +396,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "discord",
+ Filename: filename,
+ Source: "discord",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index 37a74718a..0ab70649f 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
BaseChannel: base,
config: cfg,
tokenCache: tc,
- client: lark.NewClient(cfg.AppID, cfg.AppSecret, opts...),
+ client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
}
ch.SetOwner(ch)
return ch, nil
}
func (c *FeishuChannel) Start(ctx context.Context) error {
- if c.config.AppID == "" || c.config.AppSecret == "" {
+ if c.config.AppID == "" || c.config.AppSecret() == "" {
return fmt.Errorf("feishu app_id or app_secret is empty")
}
@@ -81,7 +81,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
})
}
- dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
+ dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()).
OnP2MessageReceiveV1(c.handleMessageReceive)
runCtx, cancel := context.WithCancel(ctx)
@@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
}
c.wsClient = larkws.NewClient(
c.config.AppID,
- c.config.AppSecret,
+ c.config.AppSecret(),
larkws.WithEventHandler(dispatcher),
larkws.WithDomain(domain),
)
@@ -725,8 +725,9 @@ func (c *FeishuChannel) downloadResource(
out.Close()
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "feishu",
+ Filename: filename,
+ Source: "feishu",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err != nil {
logger.ErrorCF("feishu", "Failed to store downloaded resource", map[string]any{
diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go
index aca4ddd11..3fe9548f4 100644
--- a/pkg/channels/irc/handler.go
+++ b/pkg/channels/irc/handler.go
@@ -17,8 +17,8 @@ import (
// onConnect is called after a successful connection (and on reconnect).
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
// NickServ auth (only if SASL is not configured)
- if c.config.NickServPassword != "" && c.config.SASLUser == "" {
- conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
+ if c.config.NickServPassword() != "" && c.config.SASLUser == "" {
+ conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword())
}
// Join configured channels
diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go
index 28c59b540..289ce2c9b 100644
--- a/pkg/channels/irc/irc.go
+++ b/pkg/channels/irc/irc.go
@@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error {
Nick: c.config.Nick,
User: user,
RealName: realName,
- Password: c.config.Password,
+ Password: c.config.Password(),
UseTLS: c.config.TLS,
RequestCaps: caps,
QuitMessage: "Goodbye",
@@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error {
}
// SASL auth (takes priority over NickServ)
- if c.config.SASLUser != "" && c.config.SASLPassword != "" {
+ if c.config.SASLUser != "" && c.config.SASLPassword() != "" {
conn.SASLLogin = c.config.SASLUser
- conn.SASLPassword = c.config.SASLPassword
+ conn.SASLPassword = c.config.SASLPassword()
}
// Register event handlers
diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go
index 56ba02183..4eaadae70 100644
--- a/pkg/channels/line/line.go
+++ b/pkg/channels/line/line.go
@@ -62,7 +62,7 @@ type LINEChannel struct {
// NewLINEChannel creates a new LINE channel instance.
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
- if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
+ if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" {
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
@@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error {
if err != nil {
return err
}
- req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
+ req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
resp, err := c.infoClient.Do(req)
if err != nil {
@@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
return false
}
- mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
+ mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret()))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
@@ -301,8 +301,9 @@ func (c *LINEChannel) processEvent(event lineEvent) {
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "line",
+ Filename: filename,
+ Source: "line",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
@@ -654,7 +655,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
}
req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
+ req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
resp, err := c.apiClient.Do(req)
if err != nil {
@@ -679,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "line",
ExtraHeaders: map[string]string{
- "Authorization": "Bearer " + c.config.ChannelAccessToken,
+ "Authorization": "Bearer " + c.config.ChannelAccessToken(),
},
})
}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 9fc168618..91a1b3735 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -353,7 +353,7 @@ func (m *Manager) initChannel(name, displayName string) {
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager")
- if channels.Telegram.Enabled && channels.Telegram.Token != "" {
+ if channels.Telegram.Enabled && channels.Telegram.Token() != "" {
m.initChannel("telegram", "Telegram")
}
@@ -370,7 +370,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("feishu", "Feishu")
}
- if channels.Discord.Enabled && channels.Discord.Token != "" {
+ if channels.Discord.Enabled && channels.Discord.Token() != "" {
m.initChannel("discord", "Discord")
}
@@ -386,18 +386,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("dingtalk", "DingTalk")
}
- if channels.Slack.Enabled && channels.Slack.BotToken != "" {
+ if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
m.initChannel("slack", "Slack")
}
if channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
- m.config.Channels.Matrix.AccessToken != "" {
+ m.config.Channels.Matrix.AccessToken() != "" {
m.initChannel("matrix", "Matrix")
}
- if channels.LINE.Enabled && channels.LINE.ChannelAccessToken != "" {
+ if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
m.initChannel("line", "LINE")
}
@@ -405,13 +405,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("onebot", "OneBot")
}
- if channels.WeCom.Enabled && channels.WeCom.Token != "" {
+ if channels.WeCom.Enabled && channels.WeCom.Token() != "" {
m.initChannel("wecom", "WeCom")
}
- if m.config.Channels.WeComAIBot.Enabled &&
- ((m.config.Channels.WeComAIBot.BotID != "" && m.config.Channels.WeComAIBot.Secret != "") ||
- m.config.Channels.WeComAIBot.Token != "") {
+ if channels.WeComAIBot.Enabled && (channels.WeComAIBot.Token() != "" ||
+ (channels.WeComAIBot.Secret() != "" && channels.WeComAIBot.BotID != "")) {
m.initChannel("wecom_aibot", "WeCom AI Bot")
}
@@ -419,11 +418,11 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("wecom_app", "WeCom App")
}
- if channels.Weixin.Enabled && channels.Weixin.Token != "" {
+ if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
m.initChannel("weixin", "Weixin")
}
- if channels.Pico.Enabled && channels.Pico.Token != "" {
+ if channels.Pico.Enabled && channels.Pico.Token() != "" {
m.initChannel("pico", "Pico")
}
diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go
index 57cb05412..86572e336 100644
--- a/pkg/channels/manager_channel.go
+++ b/pkg/channels/manager_channel.go
@@ -21,6 +21,7 @@ func toChannelHashes(cfg *config.Config) map[string]string {
if !value["enabled"].(bool) {
continue
}
+ hiddenValues(key, value, ch)
valueBytes, _ := json.Marshal(value)
hash := md5.Sum(valueBytes)
result[key] = hex.EncodeToString(hash[:])
@@ -29,6 +30,49 @@ func toChannelHashes(cfg *config.Config) map[string]string {
return result
}
+func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
+ switch key {
+ case "pico":
+ value["token"] = ch.Pico.Token()
+ case "telegram":
+ value["token"] = ch.Telegram.Token()
+ case "discord":
+ value["token"] = ch.Discord.Token()
+ case "slack":
+ value["bot_token"] = ch.Slack.BotToken()
+ value["app_token"] = ch.Slack.AppToken()
+ case "matrix":
+ value["token"] = ch.Matrix.AccessToken()
+ case "onebot":
+ value["token"] = ch.OneBot.AccessToken()
+ case "line":
+ value["token"] = ch.LINE.ChannelAccessToken()
+ value["secret"] = ch.LINE.ChannelSecret()
+ case "wecom":
+ value["token"] = ch.WeCom.Token()
+ value["key"] = ch.WeCom.EncodingAESKey()
+ case "wecom_app":
+ value["token"] = ch.WeComApp.Token()
+ value["secret"] = ch.WeComApp.CorpSecret()
+ case "wecom_aibot":
+ value["token"] = ch.WeComAIBot.Token()
+ value["key"] = ch.WeComAIBot.EncodingAESKey()
+ value["secret"] = ch.WeComAIBot.Secret()
+ case "dingtalk":
+ value["secret"] = ch.QQ.AppSecret()
+ case "qq":
+ value["secret"] = ch.DingTalk.ClientSecret()
+ case "irc":
+ value["password"] = ch.IRC.Password()
+ value["serv_password"] = ch.IRC.NickServPassword()
+ value["sasl_password"] = ch.IRC.SASLPassword()
+ case "feishu":
+ value["app_secret"] = ch.Feishu.AppSecret()
+ value["encrypt_key"] = ch.Feishu.EncryptKey()
+ value["verification_token"] = ch.Feishu.VerificationToken()
+ }
+}
+
func compareChannels(old, news map[string]string) (added, removed []string) {
for key, newHash := range news {
if oldHash, ok := old[key]; ok {
@@ -82,5 +126,61 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig,
return nil, err
}
+ updateKeys(result, &ch)
+
return result, nil
}
+
+func updateKeys(newcfg, old *config.ChannelsConfig) {
+ if newcfg.Pico.Enabled {
+ newcfg.Pico.SetToken(old.Pico.Token())
+ }
+ if newcfg.Telegram.Enabled {
+ newcfg.Telegram.SetToken(old.Telegram.Token())
+ }
+ if newcfg.Discord.Enabled {
+ newcfg.Discord.SetToken(old.Discord.Token())
+ }
+ if newcfg.Slack.Enabled {
+ newcfg.Slack.SetBotToken(old.Slack.BotToken())
+ newcfg.Slack.SetAppToken(old.Slack.AppToken())
+ }
+ if newcfg.Matrix.Enabled {
+ newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken())
+ }
+ if newcfg.OneBot.Enabled {
+ newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken())
+ }
+ if newcfg.LINE.Enabled {
+ newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken())
+ newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
+ }
+ if newcfg.WeCom.Enabled {
+ newcfg.WeCom.SetToken(old.WeCom.Token())
+ newcfg.WeCom.SetEncodingAESKey(old.WeCom.EncodingAESKey())
+ }
+ if newcfg.WeComApp.Enabled {
+ newcfg.WeComApp.SetToken(old.WeComApp.Token())
+ newcfg.WeComApp.SetCorpSecret(old.WeComApp.CorpSecret())
+ }
+ if newcfg.WeComAIBot.Enabled {
+ newcfg.WeComAIBot.SetToken(old.WeComAIBot.Token())
+ newcfg.WeComAIBot.SetEncodingAESKey(old.WeComAIBot.EncodingAESKey())
+ }
+ if newcfg.DingTalk.Enabled {
+ newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
+ }
+ if newcfg.QQ.Enabled {
+ newcfg.QQ.SetAppSecret(old.QQ.AppSecret())
+ }
+ if newcfg.IRC.Enabled {
+ newcfg.IRC.SetPassword(old.IRC.Password())
+ newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword())
+ newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword())
+ }
+ if newcfg.Feishu.Enabled {
+ newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret())
+ newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey())
+ newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken())
+ }
+}
diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go
index 651764c4f..e17dcf17d 100644
--- a/pkg/channels/manager_channel_test.go
+++ b/pkg/channels/manager_channel_test.go
@@ -31,7 +31,7 @@ func TestToChannelHashes(t *testing.T) {
added, removed = compareChannels(results2, results3)
assert.EqualValues(t, []string{"dingtalk"}, removed)
assert.EqualValues(t, []string{"telegram"}, added)
- cfg3.Channels.Telegram.Token = "114314"
+ cfg3.Channels.Telegram.SetToken("114314")
results4 := toChannelHashes(cfg3)
assert.Equal(t, 1, len(results4))
logger.Debugf("results4: %v", results4)
@@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) {
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, "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, "", cc.Telegram.Token())
assert.Equal(t, false, cc.Telegram.Enabled)
}
diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go
index 4cbe95c5c..98c607d0b 100644
--- a/pkg/channels/matrix/matrix.go
+++ b/pkg/channels/matrix/matrix.go
@@ -186,7 +186,7 @@ type MatrixChannel struct {
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
homeserver := strings.TrimSpace(cfg.Homeserver)
userID := strings.TrimSpace(cfg.UserID)
- accessToken := strings.TrimSpace(cfg.AccessToken)
+ accessToken := strings.TrimSpace(cfg.AccessToken())
if homeserver == "" {
return nil, fmt.Errorf("matrix homeserver is required")
}
@@ -692,6 +692,9 @@ func (c *MatrixChannel) extractInboundMedia(
func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string {
if store := c.GetMediaStore(); store != nil {
+ if meta.CleanupPolicy == "" {
+ meta.CleanupPolicy = media.CleanupPolicyDeleteOnCleanup
+ }
ref, err := store.Store(localPath, meta, scope)
if err == nil {
return ref
diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go
index 62a9eb34a..048be48eb 100644
--- a/pkg/channels/onebot/onebot.go
+++ b/pkg/channels/onebot/onebot.go
@@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error {
dialer.HandshakeTimeout = 10 * time.Second
header := make(map[string][]string)
- if c.config.AccessToken != "" {
- header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
+ if c.config.AccessToken() != "" {
+ header["Authorization"] = []string{"Bearer " + c.config.AccessToken()}
}
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
@@ -749,8 +749,9 @@ func (c *OneBotChannel) parseMessageSegments(
storeFile := func(localPath, filename string) string {
if store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "onebot",
+ Filename: filename,
+ Source: "onebot",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
index 77e7bbdb6..86ce98b06 100644
--- a/pkg/channels/pico/pico.go
+++ b/pkg/channels/pico/pico.go
@@ -64,7 +64,7 @@ type PicoChannel struct {
// NewPicoChannel creates a new Pico Protocol channel.
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
- if cfg.Token == "" {
+ if cfg.Token() == "" {
return nil, fmt.Errorf("pico token is required")
}
@@ -297,7 +297,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers)
// 3. Query parameter "token" (only when AllowTokenQuery is on)
func (c *PicoChannel) authenticate(r *http.Request) bool {
- token := c.config.Token
+ token := c.config.Token()
if token == "" {
return false
}
@@ -328,7 +328,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
// matchedSubprotocol returns the "token." subprotocol that matches
// the configured token, or "" if none do.
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
- token := c.config.Token
+ token := c.config.Token()
for _, proto := range websocket.Subprotocols(r) {
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
return proto
diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go
index 2cd6e1747..cd66964dd 100644
--- a/pkg/channels/qq/qq.go
+++ b/pkg/channels/qq/qq.go
@@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
}
func (c *QQChannel) Start(ctx context.Context) error {
- if c.config.AppID == "" || c.config.AppSecret == "" {
+ if c.config.AppID == "" || c.config.AppSecret() == "" {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
@@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
// create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
- AppSecret: c.config.AppSecret,
+ AppSecret: c.config.AppSecret(),
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
@@ -719,9 +719,10 @@ func (c *QQChannel) extractInboundAttachments(
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",
+ Filename: qqAttachmentFilename(attachment),
+ ContentType: attachment.ContentType,
+ Source: "qq",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go
index 3ee849621..f03283ea4 100644
--- a/pkg/channels/slack/slack.go
+++ b/pkg/channels/slack/slack.go
@@ -37,13 +37,13 @@ type slackMessageRef struct {
}
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
- if cfg.BotToken == "" || cfg.AppToken == "" {
+ if cfg.BotToken() == "" || cfg.AppToken() == "" {
return nil, fmt.Errorf("slack bot_token and app_token are required")
}
api := slack.New(
- cfg.BotToken,
- slack.OptionAppLevelToken(cfg.AppToken),
+ cfg.BotToken(),
+ slack.OptionAppLevelToken(cfg.AppToken()),
)
socketClient := socketmode.New(api)
@@ -327,8 +327,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "slack",
+ Filename: filename,
+ Source: "slack",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
@@ -515,7 +516,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
LoggerPrefix: "slack",
ExtraHeaders: map[string]string{
- "Authorization": "Bearer " + c.config.BotToken,
+ "Authorization": "Bearer " + c.config.BotToken(),
},
})
}
diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go
index 30e0d2d73..23a7ee5c4 100644
--- a/pkg/channels/slack/slack_test.go
+++ b/pkg/channels/slack/slack_test.go
@@ -102,10 +102,8 @@ func TestNewSlackChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing bot token", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "",
- AppToken: "xapp-test",
- }
+ cfg := config.SlackConfig{}
+ cfg.SetAppToken("xapp-test")
_, err := NewSlackChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing bot_token, got nil")
@@ -113,10 +111,8 @@ func TestNewSlackChannel(t *testing.T) {
})
t.Run("missing app token", func(t *testing.T) {
- cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "",
- }
+ cfg := config.SlackConfig{}
+ cfg.SetBotToken("xoxb-test")
_, err := NewSlackChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing app_token, got nil")
@@ -125,10 +121,10 @@ func TestNewSlackChannel(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
AllowFrom: []string{"U123"},
}
+ cfg.SetBotToken("xoxb-test")
+ cfg.SetAppToken("xapp-test")
ch, err := NewSlackChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -147,10 +143,10 @@ func TestSlackChannelIsAllowed(t *testing.T) {
t.Run("empty allowlist allows all", func(t *testing.T) {
cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
AllowFrom: []string{},
}
+ cfg.SetBotToken("xoxb-test")
+ cfg.SetAppToken("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
if !ch.IsAllowed("U_ANYONE") {
t.Error("empty allowlist should allow all users")
@@ -159,10 +155,10 @@ func TestSlackChannelIsAllowed(t *testing.T) {
t.Run("allowlist restricts users", func(t *testing.T) {
cfg := config.SlackConfig{
- BotToken: "xoxb-test",
- AppToken: "xapp-test",
AllowFrom: []string{"U_ALLOWED"},
}
+ cfg.SetBotToken("xoxb-test")
+ cfg.SetAppToken("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
if !ch.IsAllowed("U_ALLOWED") {
t.Error("allowed user should pass allowlist check")
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 3eb89c636..f62d6d008 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -83,7 +83,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
- bot, err := telego.NewBot(telegramCfg.Token, opts...)
+ bot, err := telego.NewBot(telegramCfg.Token(), opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
@@ -561,8 +561,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
- Filename: filename,
- Source: "telegram",
+ Filename: filename,
+ Source: "telegram",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err == nil {
return ref
diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go
index 2264b8492..c5e148185 100644
--- a/pkg/channels/wecom/aibot.go
+++ b/pkg/channels/wecom/aibot.go
@@ -139,7 +139,7 @@ type WeComAIBotEncryptedResponse struct {
}
// NewWeComAIBotChannel creates a WeCom AI Bot channel instance.
-// If cfg.BotID and cfg.Secret are both set, it returns a WeComAIBotWSChannel
+// 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).
@@ -147,13 +147,13 @@ func NewWeComAIBotChannel(
cfg config.WeComAIBotConfig,
messageBus *bus.MessageBus,
) (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")
+ // 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 == "" {
+ if cfg.Token() == "" || cfg.EncodingAESKey() == "" {
return nil, fmt.Errorf(
"WeCom AI Bot requires either (bot_id + secret) for WebSocket mode " +
"or (token + encoding_aes_key) for webhook mode")
@@ -350,7 +350,7 @@ func (c *WeComAIBotChannel) handleVerification(
})
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
logger.ErrorC("wecom_aibot", "Signature verification failed")
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
return
@@ -358,7 +358,7 @@ func (c *WeComAIBotChannel) handleVerification(
// Decrypt echostr
// For WeCom AI Bot (智能机器人), receiveid should be empty string
- decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
+ decrypted, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "")
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to decrypt echostr", map[string]any{
"error": err,
@@ -417,7 +417,7 @@ func (c *WeComAIBotChannel) handleMessageCallback(
}
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.ErrorC("wecom_aibot", "Signature verification failed")
http.Error(w, "Signature verification failed", http.StatusUnauthorized)
return
@@ -425,7 +425,7 @@ func (c *WeComAIBotChannel) handleMessageCallback(
// Decrypt message
// For WeCom AI Bot (智能机器人), receiveid is empty string
- decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
+ decrypted, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "")
if err != nil {
logger.ErrorCF("wecom_aibot", "Failed to decrypt message", map[string]any{
"error": err,
@@ -859,7 +859,7 @@ func (c *WeComAIBotChannel) encryptResponse(
}
// Generate signature
- signature := computeSignature(c.config.Token, timestamp, nonce, encrypted)
+ signature := computeSignature(c.config.Token(), timestamp, nonce, encrypted)
// Build encrypted response
encryptedResp := WeComAIBotEncryptedResponse{
@@ -894,7 +894,7 @@ func (c *WeComAIBotChannel) encryptEmptyResponse(timestamp, nonce string) string
// encryptMessage encrypts a plain text message for WeCom AI Bot
func (c *WeComAIBotChannel) encryptMessage(plaintext, receiveid string) (string, error) {
- aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey)
+ aesKey, err := decodeWeComAESKey(c.config.EncodingAESKey())
if err != nil {
return "", err
}
diff --git a/pkg/channels/wecom/aibot_test.go b/pkg/channels/wecom/aibot_test.go
index 957b51c38..11c4393d6 100644
--- a/pkg/channels/wecom/aibot_test.go
+++ b/pkg/channels/wecom/aibot_test.go
@@ -15,12 +15,11 @@ import (
func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
t.Run("success with valid config", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- WebhookPath: "/webhook/test",
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
+ cfg.WebhookPath = "/webhook/test"
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
@@ -40,10 +39,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
})
t.Run("error with missing token", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
+
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
@@ -52,10 +51,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
})
t.Run("error with missing encoding key", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
if err == nil {
@@ -66,10 +65,10 @@ func TestNewWeComAIBotChannel_WebhookMode(t *testing.T) {
func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
+ Enabled: true,
}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
@@ -96,11 +95,11 @@ func TestWeComAIBotWebhookChannelStartStop(t *testing.T) {
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
t.Run("default path", func(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
+
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
@@ -116,12 +115,12 @@ func TestWeComAIBotChannelWebhookPath(t *testing.T) {
t.Run("custom path", func(t *testing.T) {
customPath := "/custom/webhook"
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- WebhookPath: customPath,
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
+ cfg.WebhookPath = customPath
+
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
@@ -140,10 +139,10 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
t.Run("uses default processing message", func(t *testing.T) {
cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: validAESKey,
+ Enabled: true,
}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(validAESKey)
messageBus := bus.NewMessageBus()
channel, err := NewWeComAIBotChannel(cfg, messageBus)
@@ -187,10 +186,10 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
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.",
}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(validAESKey)
messageBus := bus.NewMessageBus()
channel, err := NewWeComAIBotChannel(cfg, messageBus)
@@ -217,11 +216,11 @@ func TestWeComAIBotChannelGetStreamResponseProcessingMessage(t *testing.T) {
}
func TestGenerateStreamID(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "testkey1234567890123456789012345678901234567",
- }
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("testkey1234567890123456789012345678901234567")
+
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
webhookCh, ok := ch.(*WeComAIBotChannel)
@@ -243,11 +242,12 @@ func TestGenerateStreamID(t *testing.T) {
}
func TestEncryptDecrypt(t *testing.T) {
- cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
- }
+ // Use a valid 43-character base64 key (企业微信标准格式)
+ cfg := config.WeComAIBotConfig{}
+ cfg.Enabled = true
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") // 43 characters
+
messageBus := bus.NewMessageBus()
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
webhookCh, ok := ch.(*WeComAIBotChannel)
@@ -266,7 +266,8 @@ func TestEncryptDecrypt(t *testing.T) {
t.Fatal("Encrypted message is empty")
}
- decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
+ // Decrypt
+ decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey(), receiveid)
if err != nil {
t.Fatalf("Failed to decrypt message: %v", err)
}
@@ -298,7 +299,7 @@ func decodeStreamResponse(t *testing.T, ch *WeComAIBotChannel, encryptedResponse
t.Fatalf("Failed to unmarshal encrypted response: %v", err)
}
- plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey, "")
+ plaintext, err := decryptMessageWithVerify(wrapped.Encrypt, ch.config.EncodingAESKey(), "")
if err != nil {
t.Fatalf("Failed to decrypt response: %v", err)
}
@@ -318,8 +319,8 @@ func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
- Secret: "test_secret",
}
+ cfg.SetSecret("test_secret")
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
@@ -339,27 +340,27 @@ func TestNewWeComAIBotChannel_WSMode(t *testing.T) {
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",
+ Enabled: true,
+ BotID: "test_bot_id",
}
+ cfg.SetSecret("test_secret")
+ cfg.SetToken("also_set")
+ cfg.SetEncodingAESKey("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.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",
}
+ cfg.SetSecret("test_secret")
messageBus := bus.NewMessageBus()
_, err := NewWeComAIBotChannel(cfg, messageBus)
// Missing bot_id alone means neither WS mode nor webhook mode is fully configured.
@@ -385,8 +386,8 @@ func TestWeComAIBotWSChannelStartStop(t *testing.T) {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
- Secret: "test_secret",
}
+ cfg.SetSecret("test_secret")
messageBus := bus.NewMessageBus()
ch, err := NewWeComAIBotChannel(cfg, messageBus)
if err != nil {
@@ -446,10 +447,10 @@ func TestWSGenerateID(t *testing.T) {
func makeWebhookChannel(t *testing.T) *WeComAIBotChannel {
t.Helper()
cfg := config.WeComAIBotConfig{
- Enabled: true,
- Token: "test_token",
- EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
+ Enabled: true,
}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG")
ch, err := NewWeComAIBotChannel(cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("create channel: %v", err)
diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go
index 830e763b9..53dd7071f 100644
--- a/pkg/channels/wecom/aibot_ws.go
+++ b/pkg/channels/wecom/aibot_ws.go
@@ -225,7 +225,7 @@ func newWeComAIBotWSChannel(
cfg config.WeComAIBotConfig,
messageBus *bus.MessageBus,
) (*WeComAIBotWSChannel, error) {
- if cfg.BotID == "" || cfg.Secret == "" {
+ if cfg.BotID == "" || cfg.Secret() == "" {
return nil, fmt.Errorf("bot_id and secret are required for WeCom AI Bot WebSocket mode")
}
@@ -433,7 +433,7 @@ func (c *WeComAIBotWSChannel) runConnection() error {
Headers: wsHeaders{ReqID: reqID},
Body: map[string]string{
"bot_id": c.config.BotID,
- "secret": c.config.Secret,
+ "secret": c.config.Secret(),
},
}, wsSubscribeTimeout)
if err != nil {
@@ -1218,8 +1218,9 @@ func (c *WeComAIBotWSChannel) storeWSMedia(
scope := channels.BuildMediaScope("wecom_aibot", chatID, msgID)
ref, err := store.Store(tmpPath, media.MediaMeta{
- Filename: msgID + ext,
- Source: "wecom_aibot",
+ Filename: msgID + ext,
+ Source: "wecom_aibot",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope)
if err != nil {
os.Remove(tmpPath)
diff --git a/pkg/channels/wecom/aibot_ws_test.go b/pkg/channels/wecom/aibot_ws_test.go
index 0a533da5d..f2f8833a1 100644
--- a/pkg/channels/wecom/aibot_ws_test.go
+++ b/pkg/channels/wecom/aibot_ws_test.go
@@ -21,8 +21,8 @@ func newTestWSChannel(t *testing.T) *WeComAIBotWSChannel {
cfg := config.WeComAIBotConfig{
Enabled: true,
BotID: "test_bot_id",
- Secret: "test_secret",
}
+ cfg.SetSecret("test_secret")
ch, err := newWeComAIBotWSChannel(cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("create WS channel: %v", err)
diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go
index 2098fcd4e..fccfc60a3 100644
--- a/pkg/channels/wecom/app.go
+++ b/pkg/channels/wecom/app.go
@@ -119,7 +119,7 @@ type PKCS7Padding struct{}
// NewWeComAppChannel creates a new WeCom App channel instance
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
- if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
+ if cfg.CorpID == "" || cfg.CorpSecret() == "" || cfg.AgentID == 0 {
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
}
@@ -497,9 +497,9 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
- "token": c.config.Token,
+ "token": c.config.Token(),
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
@@ -513,10 +513,10 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
- "encoding_aes_key": c.config.EncodingAESKey,
+ "encoding_aes_key": c.config.EncodingAESKey(),
"corp_id": c.config.CorpID,
})
- decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
+ decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@@ -575,7 +575,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom_app", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -583,7 +583,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
- decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
+ decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
@@ -689,7 +689,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() {
// refreshAccessToken gets a new access token from WeCom API
func (c *WeComAppChannel) refreshAccessToken() error {
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
- wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
+ wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret()))
resp, err := http.Get(apiURL)
if err != nil {
diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go
index 7d07041ad..502544441 100644
--- a/pkg/channels/wecom/app_test.go
+++ b/pkg/channels/wecom/app_test.go
@@ -91,10 +91,10 @@ func TestNewWeComAppChannel(t *testing.T) {
t.Run("missing corp_id", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "",
- CorpSecret: "test_secret",
- AgentID: 1000002,
+ CorpID: "",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
_, err := NewWeComAppChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing corp_id, got nil")
@@ -103,9 +103,8 @@ func TestNewWeComAppChannel(t *testing.T) {
t.Run("missing corp_secret", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "",
- AgentID: 1000002,
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
_, err := NewWeComAppChannel(cfg, msgBus)
if err == nil {
@@ -115,10 +114,10 @@ func TestNewWeComAppChannel(t *testing.T) {
t.Run("missing agent_id", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 0,
+ CorpID: "test_corp_id",
+ AgentID: 0,
}
+ cfg.SetCorpSecret("test_secret")
_, err := NewWeComAppChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing agent_id, got nil")
@@ -127,11 +126,11 @@ func TestNewWeComAppChannel(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{"user1", "user2"},
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
+ AllowFrom: []string{"user1", "user2"},
}
+ cfg.SetCorpSecret("test_secret")
ch, err := NewWeComAppChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -150,11 +149,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
t.Run("empty allowlist allows all", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{},
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
+ AllowFrom: []string{},
}
+ cfg.SetCorpSecret("test_secret")
ch, _ := NewWeComAppChannel(cfg, msgBus)
if !ch.IsAllowed("any_user") {
t.Error("empty allowlist should allow all users")
@@ -163,11 +162,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
t.Run("allowlist restricts users", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- AllowFrom: []string{"allowed_user"},
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
+ AllowFrom: []string{"allowed_user"},
}
+ cfg.SetCorpSecret("test_secret")
ch, _ := NewWeComAppChannel(cfg, msgBus)
if !ch.IsAllowed("allowed_user") {
t.Error("allowed user should pass allowlist check")
@@ -180,12 +179,11 @@ func TestWeComAppChannelIsAllowed(t *testing.T) {
func TestWeComAppVerifySignature(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetToken("test_token")
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("valid signature", func(t *testing.T) {
@@ -194,7 +192,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
- if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
+ if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -204,21 +202,20 @@ func TestWeComAppVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
+ if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
- cfgEmpty := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "",
- }
+ cfgEmpty := config.WeComAppConfig{}
+ cfgEmpty.CorpID = "test_corp_id"
+ cfgEmpty.SetCorpSecret("test_secret")
+ cfgEmpty.AgentID = 1000002
+ cfgEmpty.SetToken("")
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
- if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
@@ -228,19 +225,18 @@ func TestWeComAppDecryptMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("decrypt without AES key", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "",
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetEncodingAESKey("")
ch, _ := NewWeComAppChannel(cfg, msgBus)
// Without AES key, message should be base64 decoded only
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encoded, ch.config.EncodingAESKey())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -252,11 +248,11 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Run("decrypt with AES key", func(t *testing.T) {
aesKey := generateTestAESKeyApp()
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: aesKey,
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
+ cfg.SetEncodingAESKey(aesKey)
ch, _ := NewWeComAppChannel(cfg, msgBus)
originalMsg := "Hello "
@@ -265,7 +261,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encrypted, ch.config.EncodingAESKey())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -276,29 +272,28 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Run("invalid base64", func(t *testing.T) {
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "",
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
+ cfg.SetEncodingAESKey("")
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
+ _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey())
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
})
t.Run("invalid AES key", func(t *testing.T) {
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: "invalid_key",
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetEncodingAESKey("invalid_key")
ch, _ := NewWeComAppChannel(cfg, msgBus)
- _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey())
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -306,17 +301,16 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Run("ciphertext too short", func(t *testing.T) {
aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- EncodingAESKey: aesKey,
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetEncodingAESKey(aesKey)
ch, _ := NewWeComAppChannel(cfg, msgBus)
// Encrypt a very short message that results in ciphertext less than block size
shortData := make([]byte, 8)
- _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey())
if err == nil {
t.Error("expected error for short ciphertext, got nil")
}
@@ -326,13 +320,12 @@ func TestWeComAppDecryptMessage(t *testing.T) {
func TestWeComAppHandleVerification(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- EncodingAESKey: aesKey,
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(aesKey)
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("valid verification request", func(t *testing.T) {
@@ -394,13 +387,12 @@ func TestWeComAppHandleVerification(t *testing.T) {
func TestWeComAppHandleMessageCallback(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKeyApp()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- EncodingAESKey: aesKey,
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(aesKey)
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("valid message callback", func(t *testing.T) {
@@ -509,10 +501,10 @@ func TestWeComAppHandleMessageCallback(t *testing.T) {
func TestWeComAppProcessMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("process text message", func(t *testing.T) {
@@ -594,12 +586,11 @@ func TestWeComAppProcessMessage(t *testing.T) {
func TestWeComAppHandleWebhook(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
- Token: "test_token",
- }
+ cfg := config.WeComAppConfig{}
+ cfg.CorpID = "test_corp_id"
+ cfg.SetCorpSecret("test_secret")
+ cfg.AgentID = 1000002
+ cfg.SetToken("test_token")
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("GET request calls verification", func(t *testing.T) {
@@ -666,10 +657,10 @@ func TestWeComAppHandleWebhook(t *testing.T) {
func TestWeComAppHandleHealth(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
ch, _ := NewWeComAppChannel(cfg, msgBus)
req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil)
@@ -695,10 +686,10 @@ func TestWeComAppHandleHealth(t *testing.T) {
func TestWeComAppAccessToken(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComAppConfig{
- CorpID: "test_corp_id",
- CorpSecret: "test_secret",
- AgentID: 1000002,
+ CorpID: "test_corp_id",
+ AgentID: 1000002,
}
+ cfg.SetCorpSecret("test_secret")
ch, _ := NewWeComAppChannel(cfg, msgBus)
t.Run("get empty access token initially", func(t *testing.T) {
diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go
index 96d5a961f..22461b768 100644
--- a/pkg/channels/wecom/bot.go
+++ b/pkg/channels/wecom/bot.go
@@ -82,7 +82,7 @@ type WeComBotReplyMessage struct {
// NewWeComBotChannel creates a new WeCom Bot channel instance
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
- if cfg.Token == "" || cfg.WebhookURL == "" {
+ if cfg.Token() == "" || cfg.WebhookURL == "" {
return nil, fmt.Errorf("wecom token and webhook_url are required")
}
@@ -216,7 +216,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -225,7 +225,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
- decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
+ decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey(), "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@@ -278,7 +278,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
- if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
+ if !verifySignature(c.config.Token(), msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@@ -287,7 +287,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
- decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
+ decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey(), "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go
index d223bb6b6..7b50a86f7 100644
--- a/pkg/channels/wecom/bot_test.go
+++ b/pkg/channels/wecom/bot_test.go
@@ -89,10 +89,9 @@ func TestNewWeComBotChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing token", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
_, err := NewWeComBotChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing token, got nil")
@@ -100,10 +99,9 @@ func TestNewWeComBotChannel(t *testing.T) {
})
t.Run("missing webhook_url", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = ""
_, err := NewWeComBotChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing webhook_url, got nil")
@@ -111,11 +109,10 @@ func TestNewWeComBotChannel(t *testing.T) {
})
t.Run("valid config", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- AllowFrom: []string{"user1", "user2"},
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.AllowFrom = []string{"user1", "user2"}
ch, err := NewWeComBotChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -133,11 +130,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("empty allowlist allows all", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- AllowFrom: []string{},
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.AllowFrom = []string{}
ch, _ := NewWeComBotChannel(cfg, msgBus)
if !ch.IsAllowed("any_user") {
t.Error("empty allowlist should allow all users")
@@ -145,11 +141,10 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
})
t.Run("allowlist restricts users", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- AllowFrom: []string{"allowed_user"},
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.AllowFrom = []string{"allowed_user"}
ch, _ := NewWeComBotChannel(cfg, msgBus)
if !ch.IsAllowed("allowed_user") {
t.Error("allowed user should pass allowlist check")
@@ -162,10 +157,9 @@ func TestWeComBotChannelIsAllowed(t *testing.T) {
func TestWeComBotVerifySignature(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid signature", func(t *testing.T) {
@@ -174,7 +168,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
- if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
+ if !verifySignature(ch.config.Token(), expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@@ -184,21 +178,20 @@ func TestWeComBotVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
- if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
+ if verifySignature(ch.config.Token(), "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
- cfgEmpty := config.WeComConfig{
- Token: "",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfgEmpty := config.WeComConfig{}
+ cfgEmpty.SetToken("")
+ cfgEmpty.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
chEmpty := &WeComBotChannel{
config: cfgEmpty,
}
- if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
+ if verifySignature(chEmpty.config.Token(), "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
@@ -208,18 +201,17 @@ func TestWeComBotDecryptMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("decrypt without AES key", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- EncodingAESKey: "",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.SetEncodingAESKey("")
ch, _ := NewWeComBotChannel(cfg, msgBus)
// Without AES key, message should be base64 decoded only
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
- result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encoded, ch.config.EncodingAESKey())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -230,11 +222,10 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Run("decrypt with AES key", func(t *testing.T) {
aesKey := generateTestAESKey()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- EncodingAESKey: aesKey,
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.SetEncodingAESKey(aesKey)
ch, _ := NewWeComBotChannel(cfg, msgBus)
originalMsg := "Hello "
@@ -243,7 +234,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
- result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
+ result, err := decryptMessage(encrypted, ch.config.EncodingAESKey())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -253,28 +244,26 @@ func TestWeComBotDecryptMessage(t *testing.T) {
})
t.Run("invalid base64", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- EncodingAESKey: "",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.SetEncodingAESKey("")
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
+ _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey())
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
})
t.Run("invalid AES key", func(t *testing.T) {
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- EncodingAESKey: "invalid_key",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
+ cfg.SetEncodingAESKey("invalid_key")
ch, _ := NewWeComBotChannel(cfg, msgBus)
- _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
+ _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey())
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@@ -338,11 +327,10 @@ func TestWeComBotPKCS7Unpad(t *testing.T) {
func TestWeComBotHandleVerification(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKey()
- cfg := config.WeComConfig{
- Token: "test_token",
- EncodingAESKey: aesKey,
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(aesKey)
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid verification request", func(t *testing.T) {
@@ -404,11 +392,10 @@ func TestWeComBotHandleVerification(t *testing.T) {
func TestWeComBotHandleMessageCallback(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKey()
- cfg := config.WeComConfig{
- Token: "test_token",
- EncodingAESKey: aesKey,
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.SetEncodingAESKey(aesKey)
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
runBotMessageCallback := func(t *testing.T, jsonMsg string) *httptest.ResponseRecorder {
@@ -530,10 +517,9 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
func TestWeComBotProcessMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("process direct text message", func(t *testing.T) {
@@ -599,10 +585,9 @@ func TestWeComBotProcessMessage(t *testing.T) {
func TestWeComBotHandleWebhook(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("GET request calls verification", func(t *testing.T) {
@@ -668,10 +653,9 @@ func TestWeComBotHandleWebhook(t *testing.T) {
func TestWeComBotHandleHealth(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := config.WeComConfig{
- Token: "test_token",
- WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
- }
+ cfg := config.WeComConfig{}
+ cfg.SetToken("test_token")
+ cfg.WebhookURL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test"
ch, _ := NewWeComBotChannel(cfg, msgBus)
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
index 0332f48f6..72af27438 100644
--- a/pkg/channels/weixin/media.go
+++ b/pkg/channels/weixin/media.go
@@ -291,9 +291,10 @@ func (c *WeixinChannel) storeInboundBytes(
return "", err
}
ref, err := store.Store(tmpPath, media.MediaMeta{
- Filename: filename,
- ContentType: contentType,
- Source: "weixin",
+ Filename: filename,
+ ContentType: contentType,
+ Source: "weixin",
+ CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, basechannels.BuildMediaScope("weixin", chatID, messageID))
if err != nil {
os.Remove(tmpPath)
diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go
index 02c137b83..9672e614d 100644
--- a/pkg/channels/weixin/state.go
+++ b/pkg/channels/weixin/state.go
@@ -46,7 +46,7 @@ func picoclawHomeDir() string {
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
key := "default"
- token := strings.TrimSpace(cfg.Token)
+ token := strings.TrimSpace(cfg.Token())
if token != "" {
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
key = hex.EncodeToString(sum[:8])
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index 43c776f98..b9e821ef1 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -42,7 +42,7 @@ func init() {
// NewWeixinChannel creates a new WeixinChannel from config.
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
- api, err := NewApiClient(cfg.BaseURL, cfg.Token, cfg.Proxy)
+ api, err := NewApiClient(cfg.BaseURL, cfg.Token(), cfg.Proxy)
if err != nil {
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
}
diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go
index 115675395..62984c965 100644
--- a/pkg/channels/weixin/weixin_test.go
+++ b/pkg/channels/weixin/weixin_test.go
@@ -149,10 +149,11 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) {
home := t.TempDir()
t.Setenv(config.EnvHome, home)
- got := buildWeixinSyncBufPath(config.WeixinConfig{
+ wxCfg := config.WeixinConfig{
BaseURL: "https://ilinkai.weixin.qq.com/",
- Token: "token-123",
- })
+ }
+ wxCfg.SetToken("token-123")
+ got := buildWeixinSyncBufPath(wxCfg)
if filepath.Dir(got) != filepath.Join(home, "channels", "weixin", "sync") {
t.Fatalf("sync path dir = %q", filepath.Dir(got))
}
diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go
index 9f73b27b6..5fd8dd9bc 100644
--- a/pkg/commands/builtin_test.go
+++ b/pkg/commands/builtin_test.go
@@ -42,8 +42,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
if !strings.Contains(reply, "/list [models|channels|agents|skills]") {
t.Fatalf("/help reply missing /list usage, got %q", reply)
}
- if !strings.Contains(reply, "/use [message]") {
- t.Fatalf("/help reply missing /use usage, got %q", reply)
+ if !strings.Contains(reply, "/use ") {
+ if !strings.Contains(reply, "/use [message]") {
+ t.Fatalf("/help reply missing /use usage, got %q", reply)
+ }
}
}
@@ -146,3 +148,43 @@ func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
t.Fatalf("/list agents reply=%q, want agent IDs", reply)
}
}
+
+func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) {
+ rt := &Runtime{
+ ListSkillNames: func() []string {
+ return []string{"shell", "git"}
+ },
+ }
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), rt)
+
+ var reply string
+ res := ex.Execute(context.Background(), Request{
+ Text: "/list skills",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("/list skills: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "shell") || !strings.Contains(reply, "git") {
+ t.Fatalf("/list skills reply=%q, want installed skill names", reply)
+ }
+}
+
+func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
+ defs := BuiltinDefinitions()
+ ex := NewExecutor(NewRegistry(defs), nil)
+
+ res := ex.Execute(context.Background(), Request{
+ Text: "/use shell run ls",
+ })
+ if res.Outcome != OutcomePassthrough {
+ t.Fatalf("/use outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
+ }
+ if res.Command != "use" {
+ t.Fatalf("/use command=%q, want=%q", res.Command, "use")
+ }
+}
diff --git a/pkg/commands/show_list_handlers_test.go b/pkg/commands/show_list_handlers_test.go
index 047708f0f..28d481b67 100644
--- a/pkg/commands/show_list_handlers_test.go
+++ b/pkg/commands/show_list_handlers_test.go
@@ -61,6 +61,9 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
GetEnabledChannels: func() []string {
return []string{"telegram"}
},
+ ListSkillNames: func() []string {
+ return []string{"shell"}
+ },
}
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
@@ -82,4 +85,20 @@ func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
if !strings.Contains(reply, "telegram") {
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
}
+
+ reply = ""
+ res = ex.Execute(context.Background(), Request{
+ Channel: "whatsapp",
+ Text: "/list skills",
+ Reply: func(text string) error {
+ reply = text
+ return nil
+ },
+ })
+ if res.Outcome != OutcomeHandled {
+ t.Fatalf("whatsapp /list skills outcome=%v, want=%v", res.Outcome, OutcomeHandled)
+ }
+ if !strings.Contains(reply, "shell") {
+ t.Fatalf("whatsapp /list skills reply=%q, expected installed skills content", reply)
+ }
}
diff --git a/pkg/config/SECURITY_CONFIG.md b/pkg/config/SECURITY_CONFIG.md
new file mode 100644
index 000000000..c5aed54ae
--- /dev/null
+++ b/pkg/config/SECURITY_CONFIG.md
@@ -0,0 +1,551 @@
+# Security Configuration Refactoring
+
+## Overview
+
+This refactoring introduces a `.security.yml` file to store all sensitive data (API keys, tokens, secrets, passwords) separately from the main configuration. This improves security by:
+
+1. **Separation of concerns**: Configuration settings and secrets are in separate files
+2. **Easier sharing**: The main config can be shared without exposing sensitive data
+3. **Better version control**: `.security.yml` can be added to `.gitignore`
+4. **Flexible deployment**: Different environments can use different security files
+
+## File Structure
+
+```
+~/.picoclaw/
+├── config.json # Main configuration (safe to share)
+└── .security.yml # Security data (never share)
+```
+
+## Usage
+
+### Basic Configuration
+
+In your `config.json`, use `ref:` references to point to values in `.security.yml`:
+
+```json
+{
+ "version": 1,
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api.openai.com/v1",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "ref:channels.telegram.token"
+ }
+ }
+}
+```
+
+### Security Configuration
+
+In your `.security.yml`, store the actual values:
+
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-your-actual-api-key-1"
+ - "sk-your-actual-api-key-2" # Optional: Multiple keys for failover
+ claude-sonnet-4.6:
+ api_keys:
+ - "sk-your-actual-anthropic-key" # Single key in array format
+
+channels:
+ telegram:
+ token: "your-telegram-bot-token"
+
+web:
+ brave:
+ api_keys:
+ - "BSAyour-brave-api-key-1"
+ - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
+ tavily:
+ api_keys:
+ - "tvly-your-tavily-api-key" # Single key in array format
+ glm_search:
+ api_key: "your-glm-search-api-key" # GLMSearch uses single key format
+```
+
+## Reference Format
+
+### Model API Keys
+
+Format: `ref:model_list..api_key`
+
+Example: `ref:model_list.gpt-5.4.api_key`
+
+### Channel Tokens/Secrets
+
+Format: `ref:channels..`
+
+Examples:
+- `ref:channels.telegram.token`
+- `ref:channels.feishu.app_secret`
+- `ref:channels.feishu.encrypt_key`
+- `ref:channels.feishu.verification_token`
+- `ref:channels.discord.token`
+- `ref:channels.qq.app_secret`
+- `ref:channels.dingtalk.client_secret`
+- `ref:channels.slack.bot_token`
+- `ref:channels.slack.app_token`
+- `ref:channels.matrix.access_token`
+- `ref:channels.line.channel_secret`
+- `ref:channels.line.channel_access_token`
+- `ref:channels.onebot.access_token`
+- `ref:channels.wecom.token`
+- `ref:channels.wecom.encoding_aes_key`
+- `ref:channels.wecom_app.corp_secret`
+- `ref:channels.wecom_app.token`
+- `ref:channels.wecom_app.encoding_aes_key`
+- `ref:channels.wecom_aibot.token`
+- `ref:channels.wecom_aibot.encoding_aes_key`
+- `ref:channels.pico.token`
+- `ref:channels.irc.password`
+- `ref:channels.irc.nickserv_password`
+- `ref:channels.irc.sasl_password`
+
+### Web Tool API Keys
+
+Format: `ref:web..`
+
+Examples:
+- `ref:web.brave.api_key`
+- `ref:web.tavily.api_key`
+- `ref:web.perplexity.api_key`
+- `ref:web.glm_search.api_key`
+
+### Skills Registry Tokens
+
+Format: `ref:skills..`
+
+Examples:
+- `ref:skills.github.token`
+- `ref:skills.clawhub.auth_token`
+
+## Backward Compatibility
+
+The refactoring maintains full backward compatibility:
+
+1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
+2. **Mixed usage**: You can mix `ref:` references and direct values
+3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work)
+
+### API Key Formats in .security.yml
+
+**Models (gpt-5.4, claude-sonnet-4.6, etc.):**
+- Must use `api_keys` (array) format
+- Both single and multiple keys use array format
+
+**Web Tools (Brave, Tavily, Perplexity):**
+- Must use `api_keys` (array) format
+- Both single and multiple keys use array format
+
+**Web Tools (GLMSearch):**
+- Must use `api_key` (single string) format
+- Does NOT support array format
+
+**Channels (Telegram, Discord, etc.):**
+- Use single field names (e.g., `token`, `app_secret`)
+- Each channel uses its specific field names
+
+### Single Key (Models)
+
+Use array format with one element:
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-your-key"
+```
+
+In `config.json`:
+```json
+{
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+}
+```
+
+### Single Key (GLMSearch)
+
+Use single string format:
+```yaml
+web:
+ glm_search:
+ api_key: "your-glm-key"
+```
+
+In `config.json`:
+```json
+{
+ "api_key": "ref:web.glm_search.api_key"
+}
+```
+
+## Migration Guide
+
+### Step 1: Create .security.yml
+
+Copy the example template:
+```bash
+cp security.example.yml ~/.picoclaw/.security.yml
+```
+
+### Step 2: Fill in your actual values
+
+Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens.
+
+### Step 3: Update config.json
+
+Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references:
+
+**Before:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "sk-your-actual-api-key-here"
+ }
+ ]
+}
+```
+
+**After:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ }
+ ]
+}
+```
+
+### Step 4: Verify
+
+Restart PicoClaw and verify it loads correctly:
+```bash
+picoclaw --version
+```
+
+## Security Best Practices
+
+1. **Never commit `.security.yml`** to version control
+2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
+3. **Use different keys** for different environments (dev, staging, production)
+4. **Rotate keys regularly** and update `.security.yml`
+5. **Backup securely**: Encrypt backups containing `.security.yml`
+
+## API
+
+### LoadSecurityConfig
+
+```go
+func LoadSecurityConfig(securityPath string) (*SecurityConfig, error)
+```
+
+Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
+
+### SaveSecurityConfig
+
+```go
+func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error
+```
+
+Saves the security configuration to `.security.yml` with `0o600` permissions.
+
+### ResolveReference
+
+```go
+func (sec *SecurityConfig) ResolveReference(ref string) (string, error)
+```
+
+Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value.
+
+### SecurityPath
+
+```go
+func SecurityPath(configPath string) string
+```
+
+Returns the path to `.security.yml` relative to the config file.
+
+## Example: Complete Configuration
+
+### config.json
+```json
+{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/picoclaw-workspace",
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api.openai.com/v1",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_base": "https://api.anthropic.com/v1",
+ "api_key": "ref:model_list.claude-sonnet-4.6.api_key"
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "ref:channels.telegram.token"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "ref:web.brave.api_key"
+ }
+ }
+ }
+}
+```
+
+### .security.yml
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-actual-openai-key-1"
+ - "sk-proj-actual-openai-key-2"
+ claude-sonnet-4.6:
+ api_keys:
+ - "sk-ant-actual-anthropic-key" # Single key in array format
+
+channels:
+ telegram:
+ token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
+
+web:
+ brave:
+ api_keys:
+ - "BSAactualbravekey-1"
+ - "BSAactualbravekey-2"
+ tavily:
+ api_keys:
+ - "tvly-your-tavily-key" # Single key in array format
+ glm_search:
+ api_key: "your-glm-key" # GLMSearch uses single key format
+```
+
+## Testing
+
+The refactoring includes comprehensive tests:
+
+```bash
+go test ./pkg/config -run TestSecurityConfig
+```
+
+## Troubleshooting
+
+### Error: "model security entry not found"
+
+- Ensure the model name in your reference matches exactly in `.security.yml`
+- Check that the `model_list` section exists in `.security.yml`
+- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
+
+### Error: "failed to load security config"
+
+- Verify `.security.yml` exists in the same directory as `config.json`
+- Check the YAML syntax is valid (use a YAML validator)
+- Ensure file permissions allow reading
+
+### Error: "unknown reference path"
+
+- Verify the reference format is correct
+- Check the path structure matches the examples above
+- Ensure all required sections exist in `.security.yml`
+
+## Advanced Features
+
+### Multiple API Keys (Load Balancing & Failover)
+
+Both models and web tools support multiple API keys for improved reliability:
+
+**Benefits:**
+- **Load balancing**: Requests are distributed across multiple keys
+- **Failover**: Automatic switching to another key if one fails
+- **Rate limit management**: Distribute usage across multiple keys
+- **High availability**: Reduce downtime during API provider issues
+
+#### Example: Model with Multiple Keys
+
+**.security.yml:**
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-key-1"
+ - "sk-proj-key-2"
+ - "sk-proj-key-3"
+```
+
+**config.json:**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ }
+ ]
+}
+```
+
+#### Example: Web Tool with Multiple Keys
+
+**.security.yml:**
+```yaml
+web:
+ brave:
+ api_keys:
+ - "BSA-key-1"
+ - "BSA-key-2"
+ tavily:
+ api_keys:
+ - "tvly-your-key" # Single key in array format
+ glm_search:
+ api_key: "your-glm-key" # GLMSearch uses single key format
+```
+
+**config.json:**
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "ref:web.brave.api_key"
+ },
+ "tavily": {
+ "enabled": true,
+ "api_key": "ref:web.tavily.api_key"
+ }
+ }
+ }
+}
+```
+
+#### Supported Formats
+
+**Models - Single key:**
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-your-key" # Array with one element
+```
+
+**Models - Multiple keys:**
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-your-key-1"
+ - "sk-your-key-2"
+ - "sk-your-key-3"
+```
+
+**Web Tools (Brave/Tavily/Perplexity) - Single key:**
+```yaml
+web:
+ brave:
+ api_keys:
+ - "BSA-your-key" # Array with one element
+```
+
+**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:**
+```yaml
+web:
+ brave:
+ api_keys:
+ - "BSA-key-1"
+ - "BSA-key-2"
+```
+
+**Web Tool (GLMSearch) - Single key only:**
+```yaml
+web:
+ glm_search:
+ api_key: "your-glm-key" # Single string (NOT array)
+```
+
+All formats work identically in `config.json` - you always use the same reference format:
+```json
+{
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+}
+```
+
+### Model Indexing for Load Balancing
+
+When you have multiple models with the same base name but different API keys, you can use indexed names:
+
+**.security.yml:**
+```yaml
+model_list:
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-key-1"
+ - "sk-proj-key-2"
+```
+
+The system will automatically expand this into multiple model entries with fallback support.
+
+### Environment Variables
+
+You can override any security value using environment variables:
+
+**For models:**
+```bash
+export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env"
+```
+
+**For channels:**
+```bash
+export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
+```
+
+**For web tools:**
+```bash
+export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env"
+```
+
+Environment variables follow this pattern: `PICOCLAW____` with dots replaced by underscores and converted to uppercase.
+
+### Multiple API Keys Not Working
+
+- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch)
+- Check that the array format is correct in YAML (proper indentation)
+- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
+- GLMSearch MUST use `api_key` (single string format)
+- The reference in `config.json` is the same regardless of single or multiple keys
+
+### Load Balancing/Failover Issues
+
+- Verify all API keys in the `api_keys` array are valid
+- Check that all keys have the same rate limits and permissions
+- Monitor logs to see which keys are being used and failing
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 7c7b79959..33919d9d7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -10,8 +10,10 @@ import (
"github.com/caarlos0/env/v11"
+ "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
// rrCounter is a global counter for round-robin load balancing across models.
@@ -76,13 +78,17 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
return nil
}
+// CurrentVersion is the latest config schema version
+const CurrentVersion = 1
+
+// Config is the current config structure with version support
type Config struct {
+ Version int `json:"version"` // Config schema version for migration
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
- Providers ProvidersConfig `json:"providers,omitempty"`
- ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
+ ModelList []*ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
Hooks HooksConfig `json:"hooks,omitempty"`
Tools ToolsConfig `json:"tools"`
@@ -91,6 +97,21 @@ type Config struct {
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"`
+
+ security *SecurityConfig
+}
+
+func (c *Config) WithSecurity(sec *SecurityConfig) *Config {
+ if sec == nil {
+ c.security = sec
+ return c
+ }
+ err := applySecurityConfig(c, sec)
+ if err != nil {
+ return nil
+ }
+ c.security = sec
+ return c
}
type HooksConfig struct {
@@ -133,19 +154,13 @@ type BuildInfo struct {
// MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty
-func (c Config) MarshalJSON() ([]byte, error) {
+func (c *Config) MarshalJSON() ([]byte, error) {
type Alias Config
aux := &struct {
- Providers *ProvidersConfig `json:"providers,omitempty"`
- Session *SessionConfig `json:"session,omitempty"`
+ Session *SessionConfig `json:"session,omitempty"`
*Alias
}{
- Alias: (*Alias)(&c),
- }
-
- // Only include providers if not empty
- if !c.Providers.IsEmpty() {
- aux.Providers = &c.Providers
+ Alias: (*Alias)(c),
}
// Only include session if not empty
@@ -270,7 +285,6 @@ type AgentDefaults struct {
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"`
@@ -285,7 +299,6 @@ type AgentDefaults struct {
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
- LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
const (
@@ -316,10 +329,7 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
// 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 {
- if d.ModelName != "" {
- return d.ModelName
- }
- return d.Model
+ return d.ModelName
}
type ChannelsConfig struct {
@@ -376,8 +386,8 @@ type WhatsAppConfig struct {
}
type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ token string
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
@@ -387,25 +397,71 @@ type TelegramConfig struct {
Streaming StreamingConfig `json:"streaming,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
+ secDirty bool
+}
+
+// Token returns the Telegram bot token
+func (c *TelegramConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the Telegram bot token
+func (c *TelegramConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
- VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ appSecret string
+ encryptKey string
+ verificationToken string
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
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"`
+ secDirty bool
+}
+
+// AppSecret returns the Feishu app secret
+func (c *FeishuConfig) AppSecret() string {
+ return c.appSecret
+}
+
+// SetAppSecret sets the Feishu app secret
+func (c *FeishuConfig) SetAppSecret(secret string) {
+ c.appSecret = secret
+ c.secDirty = true
+}
+
+// EncryptKey returns the Feishu encrypt key
+func (c *FeishuConfig) EncryptKey() string {
+ return c.encryptKey
+}
+
+// SetEncryptKey sets the Feishu encrypt key
+func (c *FeishuConfig) SetEncryptKey(key string) {
+ c.encryptKey = key
+ c.secDirty = true
+}
+
+// VerificationToken returns the Feishu verification token
+func (c *FeishuConfig) VerificationToken() string {
+ return c.verificationToken
+}
+
+// SetVerificationToken sets the Feishu verification token
+func (c *FeishuConfig) SetVerificationToken(token string) {
+ c.verificationToken = token
+ c.secDirty = true
}
type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ token string
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
@@ -413,6 +469,18 @@ type DiscordConfig struct {
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// Token returns the Discord bot token
+func (c *DiscordConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the Discord bot token
+func (c *DiscordConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
}
type MaixCamConfig struct {
@@ -424,42 +492,89 @@ 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"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ appSecret string
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"`
+ secDirty bool
+}
+
+// AppSecret returns the QQ app secret
+func (c *QQConfig) AppSecret() string {
+ return c.appSecret
+}
+
+// SetAppSecret sets the QQ app secret
+func (c *QQConfig) SetAppSecret(secret string) {
+ c.appSecret = secret
+ c.secDirty = true
}
type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
- ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ clientSecret string
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// ClientSecret returns the DingTalk client secret
+func (c *DingTalkConfig) ClientSecret() string {
+ return c.clientSecret
+}
+
+// SetClientSecret sets the DingTalk client secret
+func (c *DingTalkConfig) SetClientSecret(secret string) {
+ c.clientSecret = secret
+ c.secDirty = true
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ botToken string
+ appToken string
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// BotToken returns the Slack bot token
+func (c *SlackConfig) BotToken() string {
+ return c.botToken
+}
+
+// SetBotToken sets the Slack bot token
+func (c *SlackConfig) SetBotToken(token string) {
+ c.botToken = token
+ c.secDirty = true
+}
+
+// AppToken returns the Slack app token
+func (c *SlackConfig) AppToken() string {
+ return c.appToken
+}
+
+// SetAppToken sets the Slack app token
+func (c *SlackConfig) SetAppToken(token string) {
+ c.appToken = token
+ c.secDirty = true
}
type MatrixConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
- Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
- UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ accessToken string
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
@@ -467,12 +582,24 @@ type MatrixConfig struct {
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// AccessToken returns the Matrix access token
+func (c *MatrixConfig) AccessToken() string {
+ return c.accessToken
+}
+
+// SetAccessToken sets the Matrix access token
+func (c *MatrixConfig) SetAccessToken(token string) {
+ c.accessToken = token
+ c.secDirty = true
}
type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
- ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ channelSecret string
+ channelAccessToken string
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
@@ -481,12 +608,35 @@ type LINEConfig struct {
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// ChannelSecret returns the LINE channel secret
+func (c *LINEConfig) ChannelSecret() string {
+ return c.channelSecret
+}
+
+// SetChannelSecret sets the LINE channel secret
+func (c *LINEConfig) SetChannelSecret(secret string) {
+ c.channelSecret = secret
+ c.secDirty = true
+}
+
+// ChannelAccessToken returns the LINE channel access token
+func (c *LINEConfig) ChannelAccessToken() string {
+ return c.channelAccessToken
+}
+
+// SetChannelAccessToken sets the LINE channel access token
+func (c *LINEConfig) SetChannelAccessToken(token string) {
+ c.channelAccessToken = token
+ c.secDirty = true
}
type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ accessToken string
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
@@ -494,12 +644,24 @@ type OneBotConfig struct {
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// AccessToken returns the OneBot access token
+func (c *OneBotConfig) AccessToken() string {
+ return c.accessToken
+}
+
+// SetAccessToken sets the OneBot access token
+func (c *OneBotConfig) SetAccessToken(token string) {
+ c.accessToken = token
+ c.secDirty = true
}
type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ token string
+ encodingAESKey string
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
@@ -508,15 +670,38 @@ type WeComConfig struct {
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// Token returns the WeCom token
+func (c *WeComConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the WeCom token
+func (c *WeComConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
+}
+
+// EncodingAESKey returns the WeCom encoding AES key
+func (c *WeComConfig) EncodingAESKey() string {
+ return c.encodingAESKey
+}
+
+// SetEncodingAESKey sets the WeCom encoding AES key
+func (c *WeComConfig) SetEncodingAESKey(key string) {
+ c.encodingAESKey = key
+ c.secDirty = true
}
type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ corpSecret string
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ token string
+ encodingAESKey string
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
@@ -524,14 +709,48 @@ type WeComAppConfig struct {
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// CorpSecret returns the corporate secret for WeCom app
+func (c *WeComAppConfig) CorpSecret() string {
+ return c.corpSecret
+}
+
+// SetCorpSecret sets the corporate secret for WeCom app
+func (c *WeComAppConfig) SetCorpSecret(secret string) {
+ c.corpSecret = secret
+ c.secDirty = true
+}
+
+// Token returns the webhook token for WeCom app
+func (c *WeComAppConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the webhook token for WeCom app
+func (c *WeComAppConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
+}
+
+// EncodingAESKey returns the encoding AES key for WeCom app
+func (c *WeComAppConfig) EncodingAESKey() string {
+ return c.encodingAESKey
+}
+
+// SetEncodingAESKey sets the encoding AES key for WeCom app
+func (c *WeComAppConfig) SetEncodingAESKey(key string) {
+ c.encodingAESKey = key
+ c.secDirty = true
}
type WeComAIBotConfig struct {
- 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"`
+ 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
+ token string
+ encodingAESKey string
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"`
@@ -539,21 +758,64 @@ type WeComAIBotConfig struct {
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"`
+ secDirty bool
+}
+
+// Token returns the webhook token for WeCom AI bot
+func (c *WeComAIBotConfig) Token() string {
+ return c.token
+}
+
+// EncodingAESKey returns the encoding AES key for WeCom AI bot
+func (c *WeComAIBotConfig) EncodingAESKey() string {
+ return c.encodingAESKey
+}
+
+// SetToken sets the token for WeCom AI bot
+func (c *WeComAIBotConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
+}
+
+// SetEncodingAESKey sets the encoding AES key for WeCom AI bot
+func (c *WeComAIBotConfig) SetEncodingAESKey(key string) {
+ c.encodingAESKey = key
+ c.secDirty = true
+}
+
+func (c *WeComAIBotConfig) Secret() string {
+ return c.secret
+}
+
+func (c *WeComAIBotConfig) SetSecret(secret string) {
+ c.secret = secret
+ c.secDirty = true
}
type WeixinConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
+ token string
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+func (c *WeixinConfig) Token() string {
+ return c.token
+}
+
+func (c *WeixinConfig) SetToken(token string) *WeixinConfig {
+ c.token = token
+ c.secDirty = true
+ return c
}
type PicoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
+ token string
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
AllowOrigins []string `json:"allow_origins,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
@@ -562,6 +824,18 @@ type PicoConfig struct {
MaxConnections int `json:"max_connections,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ secDirty bool
+}
+
+// Token returns the Pico channel token
+func (c *PicoConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the Pico channel token
+func (c *PicoConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
}
type PicoClientConfig struct {
@@ -575,22 +849,53 @@ type PicoClientConfig struct {
}
type IRCConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
- Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
- TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
- Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
- User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
- RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
- Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
- NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
- SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
- SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
+ TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
+ Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
+ User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
+ RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
+ password string
+ nickServPassword string
+ SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
+ saslPassword string
Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
+ secDirty bool
+}
+
+// Password returns the IRC password
+func (c *IRCConfig) Password() string {
+ return c.password
+}
+
+// NickServPassword returns the NickServ password
+func (c *IRCConfig) NickServPassword() string {
+ return c.nickServPassword
+}
+
+// SASLPassword returns the SASL password
+func (c *IRCConfig) SASLPassword() string {
+ return c.saslPassword
+}
+
+func (c *IRCConfig) SetPassword(password string) {
+ c.password = password
+ c.secDirty = true
+}
+
+func (c *IRCConfig) SetNickServPassword(password string) {
+ c.nickServPassword = password
+ c.secDirty = true
+}
+
+func (c *IRCConfig) SetSASLPassword(password string) {
+ c.saslPassword = password
+ c.secDirty = true
}
type HeartbeatConfig struct {
@@ -604,89 +909,8 @@ type DevicesConfig struct {
}
type VoiceConfig struct {
- EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
-}
-
-type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI OpenAIProviderConfig `json:"openai"`
- LiteLLM ProviderConfig `json:"litellm"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Zhipu ProviderConfig `json:"zhipu"`
- VLLM ProviderConfig `json:"vllm"`
- Gemini ProviderConfig `json:"gemini"`
- Nvidia ProviderConfig `json:"nvidia"`
- Ollama ProviderConfig `json:"ollama"`
- Moonshot ProviderConfig `json:"moonshot"`
- ShengSuanYun ProviderConfig `json:"shengsuanyun"`
- DeepSeek ProviderConfig `json:"deepseek"`
- Cerebras ProviderConfig `json:"cerebras"`
- Vivgrid ProviderConfig `json:"vivgrid"`
- VolcEngine ProviderConfig `json:"volcengine"`
- GitHubCopilot ProviderConfig `json:"github_copilot"`
- Antigravity ProviderConfig `json:"antigravity"`
- Qwen ProviderConfig `json:"qwen"`
- Mistral ProviderConfig `json:"mistral"`
- Avian ProviderConfig `json:"avian"`
- Minimax ProviderConfig `json:"minimax"`
- LongCat ProviderConfig `json:"longcat"`
- ModelScope ProviderConfig `json:"modelscope"`
- Novita ProviderConfig `json:"novita"`
-}
-
-// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
-// Note: WebSearch is an optimization option and doesn't count as "non-empty"
-func (p ProvidersConfig) IsEmpty() bool {
- return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
- p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
- p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
- p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
- p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
- p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
- p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
- p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
- p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
- p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
- p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
- p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
- p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
- p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
- p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
- p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
- p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
- p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
- p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
- p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
- p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
- p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
- p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
- p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
- p.Novita.APIKey == "" && p.Novita.APIBase == ""
-}
-
-// MarshalJSON implements custom JSON marshaling for ProvidersConfig
-// to omit the entire section when empty
-func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
- if p.IsEmpty() {
- return []byte("null"), nil
- }
- type Alias ProvidersConfig
- return json.Marshal((*Alias)(&p))
-}
-
-type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
-}
-
-type OpenAIProviderConfig struct {
- ProviderConfig
- WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
+ EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
// ModelConfig represents a model-centric provider configuration.
@@ -703,8 +927,6 @@ type ModelConfig struct {
// HTTP-based providers
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
@@ -714,10 +936,24 @@ type ModelConfig struct {
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
// Optional optimizations
- RPM int `json:"rpm,omitempty"` // Requests per minute limit
- MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
- RequestTimeout int `json:"request_timeout,omitempty"`
- ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
+ RPM int `json:"rpm,omitempty"` // Requests per minute limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
+ ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
+
+ // from security
+ secModelName string
+ apiKeys []string
+ secDirty bool
+}
+
+// APIKey returns the first API key from apiKeys
+func (c *ModelConfig) APIKey() string {
+ if len(c.apiKeys) > 0 {
+ return c.apiKeys[0]
+ }
+ return ""
}
// Validate checks if the ModelConfig has all required fields.
@@ -731,10 +967,20 @@ func (c *ModelConfig) Validate() error {
return nil
}
+func (c *ModelConfig) SetAPIKey(value string) {
+ if len(c.apiKeys) > 0 {
+ c.apiKeys[0] = value
+ } else {
+ c.apiKeys = append(c.apiKeys, value)
+ }
+ c.secDirty = true
+}
+
type GatewayConfig struct {
- Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
- Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
- HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
+ Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
+ Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
+ HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
+ LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
type ToolDiscoveryConfig struct {
@@ -750,18 +996,68 @@ type ToolConfig struct {
}
type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ apiKeys []string
+ secDirty bool
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
+}
+
+// APIKey returns the Brave API key
+func (c *BraveConfig) APIKey() string {
+ if len(c.apiKeys) == 0 {
+ return ""
+ }
+ return c.apiKeys[0]
+}
+
+// APIKeys returns the Brave API keys
+func (c *BraveConfig) APIKeys() []string {
+ return c.apiKeys
+}
+
+// SetAPIKey sets the Brave API key
+func (c *BraveConfig) SetAPIKey(key string) {
+ c.apiKeys = []string{key}
+ c.secDirty = true
+}
+
+// SetAPIKeys sets the Brave API keys
+func (c *BraveConfig) SetAPIKeys(keys []string) {
+ c.apiKeys = keys
+ c.secDirty = true
}
type TavilyConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
- BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ apiKeys []string
+ secDirty bool
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
+// APIKey returns the Tavily API key
+func (c *TavilyConfig) APIKey() string {
+ if len(c.apiKeys) == 0 {
+ return ""
+ }
+ return c.apiKeys[0]
+}
+
+// APIKeys returns the Tavily API keys
+func (c *TavilyConfig) APIKeys() []string {
+ return c.apiKeys
+}
+
+// SetAPIKey sets the Tavily API key
+func (c *TavilyConfig) SetAPIKey(key string) {
+ c.apiKeys = []string{key}
+ c.secDirty = true
+}
+
+// SetAPIKeys sets the Tavily API keys
+func (c *TavilyConfig) SetAPIKeys(keys []string) {
+ c.apiKeys = keys
+ c.secDirty = true
}
type DuckDuckGoConfig struct {
@@ -770,10 +1066,35 @@ type DuckDuckGoConfig struct {
}
type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ apiKeys []string
+ secDirty bool
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
+// APIKey returns the Perplexity API key
+func (c *PerplexityConfig) APIKey() string {
+ if len(c.apiKeys) == 0 {
+ return ""
+ }
+ return c.apiKeys[0]
+}
+
+// SetAPIKey sets the Perplexity API key
+func (c *PerplexityConfig) SetAPIKey(key string) {
+ c.apiKeys = []string{key}
+ c.secDirty = true
+}
+
+// APIKeys returns the Perplexity API keys
+func (c *PerplexityConfig) APIKeys() []string {
+ return c.apiKeys
+}
+
+// SetAPIKeys sets the Perplexity API keys
+func (c *PerplexityConfig) SetAPIKeys(keys []string) {
+ c.apiKeys = keys
+ c.secDirty = true
}
type SearXNGConfig struct {
@@ -783,23 +1104,54 @@ type SearXNGConfig struct {
}
type GLMSearchConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
- BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
+ apiKey string
+ secDirty bool
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
// SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
}
+// APIKey returns the GLM search API key
+func (c *GLMSearchConfig) APIKey() string {
+ return c.apiKey
+}
+
+// SetAPIKey sets the GLM search API key (internal use only)
+func (c *GLMSearchConfig) SetAPIKey(key string) {
+ c.apiKey = key
+ c.secDirty = true
+}
+
+type BaiduSearchConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
+ apiKey string
+ secDirty bool
+}
+
+// APIKey returns the Baidu search API key
+func (c *BaiduSearchConfig) APIKey() string {
+ return c.apiKey
+}
+
+func (c *BaiduSearchConfig) SetAPIKey(key string) {
+ c.apiKey = key
+ c.secDirty = true
+}
+
type WebToolsConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
- Brave BraveConfig ` json:"brave"`
- Tavily TavilyConfig ` json:"tavily"`
- DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
- Perplexity PerplexityConfig ` json:"perplexity"`
- SearXNG SearXNGConfig ` json:"searxng"`
- GLMSearch GLMSearchConfig ` json:"glm_search"`
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
+ Brave BraveConfig ` json:"brave"`
+ Tavily TavilyConfig ` json:"tavily"`
+ DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
+ Perplexity PerplexityConfig ` json:"perplexity"`
+ SearXNG SearXNGConfig ` json:"searxng"`
+ GLMSearch GLMSearchConfig ` json:"glm_search"`
+ BaiduSearch BaiduSearchConfig ` json:"baidu_search"`
// PreferNative controls whether to use provider-native web search when
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
@@ -884,14 +1236,27 @@ type SkillsRegistriesConfig struct {
}
type SkillsGithubConfig struct {
- Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
+ token string
+ secDirty bool
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
+}
+
+// Token returns the GitHub token
+func (c *SkillsGithubConfig) Token() string {
+ return c.token
+}
+
+// SetToken sets the GitHub token
+func (c *SkillsGithubConfig) SetToken(token string) {
+ c.token = token
+ c.secDirty = true
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
- AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ authToken string
+ secDirty bool
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
@@ -900,6 +1265,17 @@ type ClawHubRegistryConfig struct {
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
+// AuthToken returns the ClawHub auth token
+func (c *ClawHubRegistryConfig) AuthToken() string {
+ return c.authToken
+}
+
+// SetAuthToken sets the ClawHub auth token
+func (c *ClawHubRegistryConfig) SetAuthToken(token string) {
+ c.authToken = token
+ c.secDirty = true
+}
+
// MCPServerConfig defines configuration for a single MCP server
type MCPServerConfig struct {
// Enabled indicates whether this MCP server is active
@@ -933,43 +1309,76 @@ type MCPConfig struct {
}
func LoadConfig(path string) (*Config, error) {
- cfg := DefaultConfig()
-
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
- return cfg, nil
+ return DefaultConfig(), nil
}
return nil, err
}
- // Pre-scan the JSON to check how many model_list entries the user provided.
- // Go's JSON decoder reuses existing slice backing-array elements rather than
- // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
- // would silently inherit values from the DefaultConfig template at the same
- // index position. We only reset cfg.ModelList when the user actually provides
- // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
- var tmp Config
- if err := json.Unmarshal(data, &tmp); err != nil {
- return nil, err
+ // First, try to detect config version by reading the version field
+ var versionInfo struct {
+ Version int `json:"version"`
}
- if len(tmp.ModelList) > 0 {
- cfg.ModelList = nil
+ if e := json.Unmarshal(data, &versionInfo); e != nil {
+ return nil, fmt.Errorf("failed to detect config version: %w", e)
+ }
+ if len(data) <= 10 {
+ return DefaultConfig().WithSecurity(&SecurityConfig{}), nil
}
- if err := json.Unmarshal(data, cfg); err != nil {
- return nil, err
+ // Load config based on detected version
+ var cfg *Config
+ switch versionInfo.Version {
+ case 0:
+ logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
+ // Legacy config (no version field)
+ v, e := loadConfigV0(data)
+ if e != nil {
+ return nil, e
+ }
+ cfg, e = v.Migrate()
+ if e != nil {
+ logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
+ return nil, e
+ }
+ logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
+ defer func() {
+ _ = SaveConfig(path, cfg)
+ }()
+ case CurrentVersion:
+ // Current version
+ cfg, err = loadConfig(data)
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
+ }
+
+ // Load security configuration
+ securityPath := securityPath(path)
+ sec, err := loadSecurityConfig(securityPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load security config: %w", err)
+ }
+
+ // Apply security references from .security.yml BEFORE resolveAPIKeys
+ // This resolves ref: references to actual values
+ if err := applySecurityConfig(cfg, sec); err != nil {
+ return nil, fmt.Errorf("failed to apply security config: %w", err)
}
if passphrase := credential.PassphraseProvider(); passphrase != "" {
for _, m := range cfg.ModelList {
- if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") &&
- !strings.HasPrefix(m.APIKey, "file://") {
- fmt.Fprintf(
- os.Stderr,
- "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
- m.ModelName,
- )
+ for _, k := range m.apiKeys {
+ if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") {
+ fmt.Fprintf(os.Stderr,
+ "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
+ m.ModelName)
+ break // Only warn once per model
+ }
}
}
}
@@ -982,56 +1391,264 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
+ // Resolve security fields like authToken that may contain file:// references
+ if err := resolveSecurityFields(cfg, filepath.Dir(path)); err != nil {
+ return nil, err
+ }
+
// Expand multi-key configs into separate entries for key-level failover
- cfg.ModelList = ExpandMultiKeyModels(cfg.ModelList)
+ cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
- // Auto-migrate: if only legacy providers config exists, convert to model_list
- if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
- 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
}
+ // Ensure Workspace has a default if not set
+ if cfg.Agents.Defaults.Workspace == "" {
+ homePath, _ := os.UserHomeDir()
+ if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
+ homePath = picoclawHome
+ } else if homePath != "" {
+ homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
+ }
+ cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
+ }
+
return cfg, nil
}
+func copyArray[T any](dst, src *[]T) {
+ *dst = make([]T, len(*src))
+ copy(*dst, *src)
+}
+
+// applySecurityConfig resolves all security references in config
+// It checks each field for "ref:" prefixed values and resolves them from .security.yml
+func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
+ if sec == nil {
+ return nil
+ }
+
+ if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 {
+ copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys)
+ }
+
+ if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 {
+ copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys)
+ }
+
+ if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 {
+ copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys)
+ }
+
+ if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" {
+ cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey
+ }
+
+ if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" {
+ cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey
+ }
+
+ if sec.Skills.Github != nil && sec.Skills.Github.Token != "" {
+ cfg.Tools.Skills.Github.token = sec.Skills.Github.Token
+ }
+
+ if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" {
+ cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken
+ }
+
+ names := toNameIndex(cfg.ModelList)
+ for i, model := range cfg.ModelList {
+ // Try exact match first (e.g., "abc:0" -> "abc:0")
+ if entry, exists := sec.ModelList[names[i]]; exists {
+ copyArray(&model.apiKeys, &entry.APIKeys)
+ model.secModelName = names[i]
+ continue
+ }
+
+ // Try match without index suffix (e.g., "abc" -> "abc")
+ // This allows .security.yml to use simpler keys like "test-model" instead of "test-model:0"
+ baseName := model.ModelName
+ if entry, exists := sec.ModelList[baseName]; exists {
+ copyArray(&model.apiKeys, &entry.APIKeys)
+ model.secModelName = baseName
+ continue
+ }
+ }
+
+ // Handle Telegram token
+ if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" {
+ cfg.Channels.Telegram.token = sec.Channels.Telegram.Token
+ }
+
+ // Handle Feishu credentials
+ if sec.Channels.Feishu != nil {
+ if sec.Channels.Feishu.AppSecret != "" {
+ cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret
+ }
+ if sec.Channels.Feishu.EncryptKey != "" {
+ cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey
+ }
+ if sec.Channels.Feishu.VerificationToken != "" {
+ cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken
+ }
+ }
+
+ // Handle Discord token
+ if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" {
+ cfg.Channels.Discord.token = sec.Channels.Discord.Token
+ }
+
+ // Handle Weixin token
+ if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" {
+ cfg.Channels.Discord.token = sec.Channels.Discord.Token
+ }
+
+ // Handle DingTalk client secret
+ if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" {
+ cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret
+ }
+
+ // Handle Slack tokens
+ if sec.Channels.Slack != nil {
+ if sec.Channels.Slack.BotToken != "" {
+ cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken
+ }
+ if sec.Channels.Slack.AppToken != "" {
+ cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken
+ }
+ }
+
+ // Handle Matrix access token
+ if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" {
+ cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken
+ }
+
+ // Handle LINE credentials
+ if sec.Channels.LINE != nil {
+ if sec.Channels.LINE.ChannelSecret != "" {
+ cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret
+ }
+ if sec.Channels.LINE.ChannelAccessToken != "" {
+ cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken
+ }
+ }
+
+ // Handle OneBot access token
+ if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" {
+ cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
+ }
+
+ // Handle WeCom token and encoding key
+ if sec.Channels.WeCom != nil {
+ if sec.Channels.WeCom.Token != "" {
+ cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
+ }
+ if sec.Channels.WeCom.EncodingAESKey != "" {
+ cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey
+ }
+ }
+
+ // Handle WeCom App credentials
+ if sec.Channels.WeComApp != nil {
+ if sec.Channels.WeComApp.CorpSecret != "" {
+ cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret
+ }
+ if sec.Channels.WeComApp.Token != "" {
+ cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token
+ }
+ if sec.Channels.WeComApp.EncodingAESKey != "" {
+ cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey
+ }
+ }
+
+ // Handle WeCom AI Bot credentials
+ if sec.Channels.WeComAIBot != nil {
+ if sec.Channels.WeComAIBot.Token != "" {
+ cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token
+ }
+ if sec.Channels.WeComAIBot.EncodingAESKey != "" {
+ cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey
+ }
+ if sec.Channels.WeComAIBot.Secret != "" {
+ cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret
+ }
+ }
+
+ // Handle Pico channel token
+ if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" {
+ cfg.Channels.Pico.token = sec.Channels.Pico.Token
+ }
+
+ // Handle IRC passwords
+ if sec.Channels.IRC != nil {
+ if sec.Channels.IRC.Password != "" {
+ cfg.Channels.IRC.password = sec.Channels.IRC.Password
+ }
+ if sec.Channels.IRC.NickServPassword != "" {
+ cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword
+ }
+ if sec.Channels.IRC.SASLPassword != "" {
+ cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword
+ }
+ }
+
+ // Handle QQ app secret
+ if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" {
+ cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret
+ }
+
+ cfg.security = sec
+
+ return nil
+}
+
+func toNameIndex(list []*ModelConfig) []string {
+ nameList := make([]string, 0, len(list))
+ countMap := make(map[string]int)
+ for _, model := range list {
+ name := model.ModelName
+ index := countMap[name]
+ nameList = append(nameList, fmt.Sprintf("%s:%d", name, index))
+ countMap[name]++
+ }
+ return nameList
+}
+
// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
// empty). Returns (nil, error) if any key fails to encrypt — callers must treat
// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
// and leave JSON marshaling to the caller.
-func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {
- sealed := make([]ModelConfig, len(models))
- copy(sealed, models)
+func encryptPlaintextAPIKeys(
+ models map[string]ModelSecurityEntry,
+ passphrase string,
+) (map[string]ModelSecurityEntry, error) {
+ sealed := make(map[string]ModelSecurityEntry, len(models))
changed := false
- for i := range sealed {
- m := &sealed[i]
- if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") ||
- strings.HasPrefix(m.APIKey, "file://") {
- continue
+ for k, m := range models {
+ sealedEntry := ModelSecurityEntry{APIKeys: make([]string, len(m.APIKeys))}
+
+ // Encrypt each key in APIKeys
+ for i, key := range m.APIKeys {
+ if key == "" || strings.HasPrefix(key, "enc://") || strings.HasPrefix(key, "file://") {
+ sealedEntry.APIKeys[i] = key
+ continue
+ }
+ encrypted, err := credential.Encrypt(passphrase, "", key)
+ if err != nil {
+ return nil, fmt.Errorf("cannot seal api_key for model %q: %w", k, err)
+ }
+ sealedEntry.APIKeys[i] = encrypted
+ changed = true
}
- encrypted, err := credential.Encrypt(passphrase, "", m.APIKey)
- if err != nil {
- return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err)
- }
- m.APIKey = encrypted
- changed = true
+
+ sealed[k] = sealedEntry
}
if !changed {
return nil, nil
@@ -1041,19 +1658,11 @@ 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 {
+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 {
+ for j, key := range models[i].apiKeys {
resolved, err := cr.Resolve(key)
if err != nil {
return fmt.Errorf(
@@ -1064,7 +1673,7 @@ func resolveAPIKeys(models []ModelConfig, configDir string) error {
err,
)
}
- models[i].APIKeys[j] = resolved
+ models[i].apiKeys[j] = resolved
}
}
return nil
@@ -1084,17 +1693,187 @@ func (c *Config) migrateChannelConfigs() {
}
func SaveConfig(path string, cfg *Config) error {
+ if cfg.security == nil {
+ logger.Errorf("config %#v", *cfg)
+ if len(cfg.ModelList) > 0 {
+ logger.Errorf("model[0] %#v", cfg.ModelList[0])
+ }
+ logger.ErrorC("config", "security is nil")
+ return fmt.Errorf("security is nil")
+ }
+ // Ensure version is always set when saving
+ if cfg.Version == 0 {
+ cfg.Version = CurrentVersion
+ }
+ names := toNameIndex(cfg.ModelList)
+ for i, m := range cfg.ModelList {
+ if m.secDirty {
+ if m.secModelName == "" {
+ m.secModelName = names[i]
+ }
+ cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{
+ APIKeys: m.apiKeys,
+ }
+ m.secDirty = false
+ }
+ }
+ if cfg.Channels.Pico.secDirty {
+ cfg.security.Channels.Pico = &PicoSecurity{
+ Token: cfg.Channels.Pico.Token(),
+ }
+ cfg.Channels.Pico.secDirty = false
+ }
+ if cfg.Channels.IRC.secDirty {
+ cfg.security.Channels.IRC = &IRCSecurity{
+ Password: cfg.Channels.IRC.password,
+ NickServPassword: cfg.Channels.IRC.nickServPassword,
+ SASLPassword: cfg.Channels.IRC.saslPassword,
+ }
+ cfg.Channels.IRC.secDirty = false
+ }
+ if cfg.Channels.Telegram.secDirty {
+ cfg.security.Channels.Telegram = &TelegramSecurity{
+ Token: cfg.Channels.Telegram.Token(),
+ }
+ cfg.Channels.Telegram.secDirty = false
+ }
+ if cfg.Channels.Feishu.secDirty {
+ cfg.security.Channels.Feishu = &FeishuSecurity{
+ AppSecret: cfg.Channels.Feishu.AppSecret(),
+ EncryptKey: cfg.Channels.Feishu.EncryptKey(),
+ VerificationToken: cfg.Channels.Feishu.VerificationToken(),
+ }
+ cfg.Channels.Feishu.secDirty = false
+ }
+ if cfg.Channels.Discord.secDirty {
+ cfg.security.Channels.Discord = &DiscordSecurity{
+ Token: cfg.Channels.Discord.Token(),
+ }
+ cfg.Channels.Discord.secDirty = false
+ }
+ if cfg.Channels.Weixin.secDirty {
+ cfg.security.Channels.Weixin = &WeixinSecurity{
+ Token: cfg.Channels.Weixin.Token(),
+ }
+ cfg.Channels.Discord.secDirty = false
+ }
+ if cfg.Channels.QQ.secDirty {
+ cfg.security.Channels.QQ = &QQSecurity{
+ AppSecret: cfg.Channels.QQ.AppSecret(),
+ }
+ cfg.Channels.QQ.secDirty = false
+ }
+ if cfg.Channels.DingTalk.secDirty {
+ cfg.security.Channels.DingTalk = &DingTalkSecurity{
+ ClientSecret: cfg.Channels.DingTalk.ClientSecret(),
+ }
+ cfg.Channels.DingTalk.secDirty = false
+ }
+ if cfg.Channels.Slack.secDirty {
+ cfg.security.Channels.Slack = &SlackSecurity{
+ BotToken: cfg.Channels.Slack.BotToken(),
+ AppToken: cfg.Channels.Slack.AppToken(),
+ }
+ cfg.Channels.Slack.secDirty = false
+ }
+ if cfg.Channels.Matrix.secDirty {
+ cfg.security.Channels.Matrix = &MatrixSecurity{
+ AccessToken: cfg.Channels.Matrix.AccessToken(),
+ }
+ cfg.Channels.Matrix.secDirty = false
+ }
+ if cfg.Channels.LINE.secDirty {
+ cfg.security.Channels.LINE = &LINESecurity{
+ ChannelSecret: cfg.Channels.LINE.ChannelSecret(),
+ ChannelAccessToken: cfg.Channels.LINE.ChannelAccessToken(),
+ }
+ cfg.Channels.LINE.secDirty = false
+ }
+ if cfg.Channels.OneBot.secDirty {
+ cfg.security.Channels.OneBot = &OneBotSecurity{
+ AccessToken: cfg.Channels.OneBot.AccessToken(),
+ }
+ cfg.Channels.OneBot.secDirty = false
+ }
+ if cfg.Channels.WeCom.secDirty {
+ cfg.security.Channels.WeCom = &WeComSecurity{
+ Token: cfg.Channels.WeCom.Token(),
+ EncodingAESKey: cfg.Channels.WeCom.EncodingAESKey(),
+ }
+ cfg.Channels.WeCom.secDirty = false
+ }
+ if cfg.Channels.WeComApp.secDirty {
+ cfg.security.Channels.WeComApp = &WeComAppSecurity{
+ CorpSecret: cfg.Channels.WeComApp.CorpSecret(),
+ Token: cfg.Channels.WeComApp.Token(),
+ EncodingAESKey: cfg.Channels.WeComApp.EncodingAESKey(),
+ }
+ cfg.Channels.WeComApp.secDirty = false
+ }
+ if cfg.Channels.WeComAIBot.secDirty {
+ cfg.security.Channels.WeComAIBot = &WeComAIBotSecurity{
+ Token: cfg.Channels.WeComAIBot.Token(),
+ EncodingAESKey: cfg.Channels.WeComAIBot.EncodingAESKey(),
+ Secret: cfg.Channels.WeComAIBot.Secret(),
+ }
+ cfg.Channels.WeComAIBot.secDirty = false
+ }
+ if cfg.Tools.Web.Brave.secDirty {
+ cfg.security.Web.Brave = &BraveSecurity{
+ APIKeys: cfg.Tools.Web.Brave.APIKeys(),
+ }
+ cfg.Tools.Web.Brave.secDirty = false
+ }
+ if cfg.Tools.Web.Tavily.secDirty {
+ cfg.security.Web.Tavily = &TavilySecurity{
+ APIKeys: cfg.Tools.Web.Tavily.APIKeys(),
+ }
+ cfg.Tools.Web.Tavily.secDirty = false
+ }
+ if cfg.Tools.Web.Perplexity.secDirty {
+ cfg.security.Web.Perplexity = &PerplexitySecurity{
+ APIKeys: cfg.Tools.Web.Perplexity.APIKeys(),
+ }
+ cfg.Tools.Web.Perplexity.secDirty = false
+ }
+ if cfg.Tools.Web.GLMSearch.secDirty {
+ cfg.security.Web.GLMSearch = &GLMSearchSecurity{
+ APIKey: cfg.Tools.Web.GLMSearch.APIKey(),
+ }
+ cfg.Tools.Web.GLMSearch.secDirty = false
+ }
+ if cfg.Tools.Web.BaiduSearch.secDirty {
+ cfg.security.Web.BaiduSearch = &BaiduSearchSecurity{
+ APIKey: cfg.Tools.Web.BaiduSearch.APIKey(),
+ }
+ cfg.Tools.Web.BaiduSearch.secDirty = false
+ }
+ if cfg.Tools.Skills.Github.secDirty {
+ cfg.security.Skills.Github = &GithubSecurity{
+ Token: cfg.Tools.Skills.Github.Token(),
+ }
+ cfg.Tools.Skills.Github.secDirty = false
+ }
+ if cfg.Tools.Skills.Registries.ClawHub.secDirty {
+ cfg.security.Skills.ClawHub = &ClawHubSecurity{
+ AuthToken: cfg.Tools.Skills.Registries.ClawHub.AuthToken(),
+ }
+ cfg.Tools.Skills.Registries.ClawHub.secDirty = false
+ }
+
if passphrase := credential.PassphraseProvider(); passphrase != "" {
- sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)
+ sealed, err := encryptPlaintextAPIKeys(cfg.security.ModelList, passphrase)
if err != nil {
return err
}
if sealed != nil {
- tmp := *cfg
- tmp.ModelList = sealed
- cfg = &tmp
+ cfg.security.ModelList = sealed
}
}
+ if err := saveSecurityConfig(securityPath(path), cfg.security); err != nil {
+ logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err})
+ return err
+ }
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
@@ -1107,53 +1886,6 @@ func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace)
}
-func (c *Config) GetAPIKey() string {
- if c.Providers.OpenRouter.APIKey != "" {
- return c.Providers.OpenRouter.APIKey
- }
- if c.Providers.Anthropic.APIKey != "" {
- return c.Providers.Anthropic.APIKey
- }
- if c.Providers.OpenAI.APIKey != "" {
- return c.Providers.OpenAI.APIKey
- }
- if c.Providers.Gemini.APIKey != "" {
- return c.Providers.Gemini.APIKey
- }
- if c.Providers.Zhipu.APIKey != "" {
- return c.Providers.Zhipu.APIKey
- }
- if c.Providers.Groq.APIKey != "" {
- return c.Providers.Groq.APIKey
- }
- if c.Providers.VLLM.APIKey != "" {
- return c.Providers.VLLM.APIKey
- }
- if c.Providers.ShengSuanYun.APIKey != "" {
- return c.Providers.ShengSuanYun.APIKey
- }
- if c.Providers.Cerebras.APIKey != "" {
- return c.Providers.Cerebras.APIKey
- }
- return ""
-}
-
-func (c *Config) GetAPIBase() string {
- if c.Providers.OpenRouter.APIKey != "" {
- if c.Providers.OpenRouter.APIBase != "" {
- return c.Providers.OpenRouter.APIBase
- }
- return "https://openrouter.ai/api/v1"
- }
- if c.Providers.Zhipu.APIKey != "" {
- return c.Providers.Zhipu.APIBase
- }
- if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
- return c.Providers.VLLM.APIBase
- }
- return ""
-}
-
func expandHome(path string) string {
if path == "" {
return path
@@ -1177,17 +1909,17 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
}
if len(matches) == 1 {
- return &matches[0], nil
+ return matches[0], nil
}
// Multiple configs - use round-robin for load balancing
idx := (rrCounter.Add(1) - 1) % uint64(len(matches))
- return &matches[idx], nil
+ return matches[idx], nil
}
// findMatches finds all ModelConfig entries with the given model_name.
-func (c *Config) findMatches(modelName string) []ModelConfig {
- var matches []ModelConfig
+func (c *Config) findMatches(modelName string) []*ModelConfig {
+ var matches []*ModelConfig
for i := range c.ModelList {
if c.ModelList[i].ModelName == modelName {
matches = append(matches, c.ModelList[i])
@@ -1196,11 +1928,6 @@ func (c *Config) findMatches(modelName string) []ModelConfig {
return matches
}
-// HasProvidersConfig checks if any provider in the old providers config has configuration.
-func (c *Config) HasProvidersConfig() bool {
- return !c.Providers.IsEmpty()
-}
-
// ValidateModelList validates all ModelConfig entries in the model_list.
// It checks that each model config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing.
@@ -1213,6 +1940,10 @@ func (c *Config) ValidateModelList() error {
return nil
}
+func (c *Config) SecurityCopyFrom(cfg *Config) {
+ c.security = cfg.security
+}
+
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
@@ -1236,28 +1967,92 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string {
return all
}
-// ExpandMultiKeyModels expands ModelConfig entries with multiple API keys into
+// resolveSecurityFields resolves file:// and enc:// references in security-sensitive fields
+// like authToken and token that are not part of ModelConfig's apiKeys
+func resolveSecurityFields(cfg *Config, configDir string) error {
+ cr := credential.NewResolver(configDir)
+
+ // Resolve Web tool API keys - set apiKey field to first resolved apiKeys entry
+ if len(cfg.Tools.Web.Brave.apiKeys) > 0 {
+ keys := cfg.Tools.Web.Brave.apiKeys
+ for i, key := range keys {
+ resolved, err := cr.Resolve(key)
+ if err != nil {
+ return fmt.Errorf("brave api_keys[%d]: %w", i, err)
+ }
+ keys[i] = resolved
+ }
+ }
+
+ if len(cfg.Tools.Web.Tavily.apiKeys) > 0 {
+ keys := cfg.Tools.Web.Tavily.apiKeys
+ for i, key := range keys {
+ resolved, err := cr.Resolve(key)
+ if err != nil {
+ return fmt.Errorf("tavily api_keys[%d]: %w", i, err)
+ }
+ keys[i] = resolved
+ }
+ }
+
+ if len(cfg.Tools.Web.Perplexity.apiKeys) > 0 {
+ keys := cfg.Tools.Web.Perplexity.apiKeys
+ for i, key := range keys {
+ resolved, err := cr.Resolve(key)
+ if err != nil {
+ return fmt.Errorf("perplexity api_keys[%d]: %w", i, err)
+ }
+ keys[i] = resolved
+ }
+ }
+
+ // GLMSearch has a private apiKey field
+ if cfg.Tools.Web.GLMSearch.apiKey != "" {
+ resolved, err := cr.Resolve(cfg.Tools.Web.GLMSearch.apiKey)
+ if err != nil {
+ return fmt.Errorf("glm api_key: %w", err)
+ }
+ cfg.Tools.Web.GLMSearch.apiKey = resolved
+ }
+
+ // Resolve Skills tokens
+ if cfg.Tools.Skills.Github.token != "" {
+ resolved, err := cr.Resolve(cfg.Tools.Skills.Github.token)
+ if err != nil {
+ return fmt.Errorf("github token: %w", err)
+ }
+ cfg.Tools.Skills.Github.token = resolved
+ }
+
+ if cfg.Tools.Skills.Registries.ClawHub.authToken != "" {
+ resolved, err := cr.Resolve(cfg.Tools.Skills.Registries.ClawHub.authToken)
+ if err != nil {
+ return fmt.Errorf("clawhub auth_token: %w", err)
+ }
+ cfg.Tools.Skills.Registries.ClawHub.authToken = resolved
+ }
+
+ return nil
+}
+
+// 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
+// - {"model_name": "gpt-4", "api_keys": ["k1"], "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]}
+// - {"model_name": "gpt-4__key_1", "api_keys": {"k2"}}
+// - {"model_name": "gpt-4__key_2", "api_keys": {"k3"}}
+func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
+ var expanded []*ModelConfig
for _, m := range models {
- keys := MergeAPIKeys(m.APIKey, m.APIKeys)
+ keys := MergeAPIKeys("", 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
+ m.apiKeys = keys
expanded = append(expanded, m)
continue
}
@@ -1272,11 +2067,11 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
expandedName := originalName + suffix
// Create a copy for the additional key
- additionalEntry := ModelConfig{
+ additionalEntry := &ModelConfig{
ModelName: expandedName,
Model: m.Model,
APIBase: m.APIBase,
- APIKey: keys[i],
+ apiKeys: []string{keys[i]},
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
@@ -1285,17 +2080,17 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
+ ExtraBody: m.ExtraBody,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
}
// Create the primary entry with first key and fallbacks
- primaryEntry := ModelConfig{
+ primaryEntry := &ModelConfig{
ModelName: originalName,
Model: m.Model,
APIBase: m.APIBase,
- APIKey: keys[0],
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
@@ -1304,6 +2099,8 @@ func ExpandMultiKeyModels(models []ModelConfig) []ModelConfig {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
+ ExtraBody: m.ExtraBody,
+ apiKeys: []string{keys[0]},
}
// Prepend new fallbacks to existing ones
diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go
new file mode 100644
index 000000000..c7c7f0028
--- /dev/null
+++ b/pkg/config/config_old.go
@@ -0,0 +1,1032 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import "encoding/json"
+
+type agentDefaultsV0 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,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Model string `json:"model" 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"`
+}
+
+// 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 *agentDefaultsV0) GetModelName() string {
+ if d.ModelName != "" {
+ return d.ModelName
+ }
+ return d.Model
+}
+
+type agentsConfigV0 struct {
+ Defaults agentDefaultsV0 `json:"defaults"`
+ List []AgentConfig `json:"list,omitempty"`
+}
+
+// configV0 represents the config structure before versioning was introduced.
+// This struct is used for loading legacy config files (version 0).
+// It is unexported since it's only used internally for migration.
+type configV0 struct {
+ Agents agentsConfigV0 `json:"agents"`
+ Bindings []AgentBinding `json:"bindings,omitempty"`
+ Session SessionConfig `json:"session,omitempty"`
+ Channels channelsConfigV0 `json:"channels"`
+ Providers providersConfigV0 `json:"providers,omitempty"`
+ ModelList []modelConfigV0 `json:"model_list"`
+ Gateway GatewayConfig `json:"gateway"`
+ Tools toolsConfigV0 `json:"tools"`
+ Heartbeat HeartbeatConfig `json:"heartbeat"`
+ Devices DevicesConfig `json:"devices"`
+}
+
+type toolsConfigV0 struct {
+ AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
+ AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
+ Web webToolsConfigV0 `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+ Skills skillsToolsConfigV0 `json:"skills"`
+ MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
+ MCP MCPConfig `json:"mcp"`
+ AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
+ EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
+ FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
+ I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
+ InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
+ ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
+ Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
+ ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
+ SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
+ Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
+ SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
+ SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
+ Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
+ WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
+ WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
+}
+
+type channelsConfigV0 struct {
+ WhatsApp WhatsAppConfig `json:"whatsapp"`
+ Telegram telegramConfigV0 `json:"telegram"`
+ Feishu feishuConfigV0 `json:"feishu"`
+ Discord discordConfigV0 `json:"discord"`
+ MaixCam maixcamConfigV0 `json:"maixcam"`
+ Weixin weixinConfigV0 `json:"weixin"`
+ QQ qqConfigV0 `json:"qq"`
+ DingTalk dingtalkConfigV0 `json:"dingtalk"`
+ Slack slackConfigV0 `json:"slack"`
+ Matrix matrixConfigV0 `json:"matrix"`
+ LINE lineConfigV0 `json:"line"`
+ OneBot onebotConfigV0 `json:"onebot"`
+ WeCom wecomConfigV0 `json:"wecom"`
+ WeComApp wecomappConfigV0 `json:"wecom_app"`
+ WeComAIBot wecomaibotConfigV0 `json:"wecom_aibot"`
+ Pico picoConfigV0 `json:"pico"`
+ IRC ircConfigV0 `json:"irc"`
+}
+
+func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
+ telegram, telegramSecurity := v.Telegram.ToTelegramConfig()
+ feishu, feishuSecurity := v.Feishu.ToFeishuConfig()
+ discord, discordSecurity := v.Discord.ToDiscordConfig()
+ maixcam := v.MaixCam.ToMaixCamConfig()
+ qq, qqSecurity := v.QQ.ToQQConfig()
+ weixin, weixinSecurity := v.Weixin.ToWeiXinConfig()
+ dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig()
+ slack, slackSecurity := v.Slack.ToSlackConfig()
+ matrix, matrixSecurity := v.Matrix.ToMatrixConfig()
+ line, lineSecurity := v.LINE.ToLINEConfig()
+ onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
+ wecom, wecomSecurity := v.WeCom.ToWeComConfig()
+ wecomapp, wecomappSecurity := v.WeComApp.ToWeComAppConfig()
+ wecomaibot, wecomaibotSecurity := v.WeComAIBot.ToWeComAIBotConfig()
+ pico, picoSecurity := v.Pico.ToPicoConfig()
+ irc, ircSecurity := v.IRC.ToIRCConfig()
+
+ return ChannelsConfig{
+ WhatsApp: v.WhatsApp,
+ Telegram: telegram,
+ Feishu: feishu,
+ Discord: discord,
+ MaixCam: maixcam,
+ QQ: qq,
+ Weixin: weixin,
+ DingTalk: dingtalk,
+ Slack: slack,
+ Matrix: matrix,
+ LINE: line,
+ OneBot: onebot,
+ WeCom: wecom,
+ WeComApp: wecomapp,
+ WeComAIBot: wecomaibot,
+ Pico: pico,
+ IRC: irc,
+ }, ChannelsSecurity{
+ Telegram: &telegramSecurity,
+ Feishu: &feishuSecurity,
+ Discord: &discordSecurity,
+ QQ: &qqSecurity,
+ Weixin: &weixinSecurity,
+ DingTalk: &dingtalkSecurity,
+ Slack: &slackSecurity,
+ Matrix: &matrixSecurity,
+ LINE: &lineSecurity,
+ OneBot: &onebotSecurity,
+ WeCom: &wecomSecurity,
+ WeComApp: &wecomappSecurity,
+ WeComAIBot: &wecomaibotSecurity,
+ Pico: &picoSecurity,
+ IRC: &ircSecurity,
+ }
+}
+
+type qqConfigV0 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"`
+ 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"`
+}
+
+func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) {
+ return QQConfig{
+ Enabled: v.Enabled,
+ AppID: v.AppID,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ MaxMessageLength: v.MaxMessageLength,
+ MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
+ SendMarkdown: v.SendMarkdown,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, QQSecurity{
+ AppSecret: v.AppSecret,
+ }
+}
+
+type telegramConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
+ UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
+}
+
+func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) {
+ return TelegramConfig{
+ Enabled: v.Enabled,
+ token: v.Token,
+ BaseURL: v.BaseURL,
+ Proxy: v.Proxy,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ UseMarkdownV2: v.UseMarkdownV2,
+ }, TelegramSecurity{
+ Token: v.Token,
+ }
+}
+
+type feishuConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ 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"`
+}
+
+func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) {
+ return FeishuConfig{
+ Enabled: v.Enabled,
+ AppID: v.AppID,
+ appSecret: v.AppSecret,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, FeishuSecurity{
+ AppSecret: v.AppSecret,
+ EncryptKey: v.EncryptKey,
+ VerificationToken: v.VerificationToken,
+ }
+}
+
+type discordConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
+}
+
+func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) {
+ return DiscordConfig{
+ Enabled: v.Enabled,
+ token: v.Token,
+ Proxy: v.Proxy,
+ AllowFrom: v.AllowFrom,
+ MentionOnly: v.MentionOnly,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, DiscordSecurity{
+ Token: v.Token,
+ }
+}
+
+type maixcamConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
+}
+
+func (v *maixcamConfigV0) ToMaixCamConfig() MaixCamConfig {
+ return MaixCamConfig{
+ Enabled: v.Enabled,
+ Host: v.Host,
+ Port: v.Port,
+ AllowFrom: v.AllowFrom,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }
+}
+
+type dingtalkConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
+}
+
+func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) {
+ return DingTalkConfig{
+ Enabled: v.Enabled,
+ ClientID: v.ClientID,
+ clientSecret: v.ClientSecret,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, DingTalkSecurity{
+ ClientSecret: v.ClientSecret,
+ }
+}
+
+type slackConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
+}
+
+func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) {
+ return SlackConfig{
+ Enabled: v.Enabled,
+ botToken: v.BotToken,
+ appToken: v.AppToken,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, SlackSecurity{
+ BotToken: v.BotToken,
+ AppToken: v.AppToken,
+ }
+}
+
+type matrixConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
+ JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
+ MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+}
+
+func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) {
+ return MatrixConfig{
+ Enabled: v.Enabled,
+ Homeserver: v.Homeserver,
+ UserID: v.UserID,
+ accessToken: v.AccessToken,
+ DeviceID: v.DeviceID,
+ JoinOnInvite: v.JoinOnInvite,
+ MessageFormat: v.MessageFormat,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, MatrixSecurity{
+ AccessToken: v.AccessToken,
+ }
+}
+
+type lineConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
+}
+
+func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) {
+ return LINEConfig{
+ Enabled: v.Enabled,
+ channelSecret: v.ChannelSecret,
+ channelAccessToken: v.ChannelAccessToken,
+ WebhookHost: v.WebhookHost,
+ WebhookPort: v.WebhookPort,
+ WebhookPath: v.WebhookPath,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, LINESecurity{
+ ChannelSecret: v.ChannelSecret,
+ ChannelAccessToken: v.ChannelAccessToken,
+ }
+}
+
+type onebotConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
+}
+
+func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) {
+ return OneBotConfig{
+ Enabled: v.Enabled,
+ WSUrl: v.WSUrl,
+ accessToken: v.AccessToken,
+ ReconnectInterval: v.ReconnectInterval,
+ GroupTriggerPrefix: v.GroupTriggerPrefix,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ Placeholder: v.Placeholder,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, OneBotSecurity{
+ AccessToken: v.AccessToken,
+ }
+}
+
+type wecomConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+ WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
+}
+
+func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) {
+ return WeComConfig{
+ Enabled: v.Enabled,
+ token: v.Token,
+ encodingAESKey: v.EncodingAESKey,
+ WebhookURL: v.WebhookURL,
+ WebhookHost: v.WebhookHost,
+ WebhookPort: v.WebhookPort,
+ WebhookPath: v.WebhookPath,
+ AllowFrom: v.AllowFrom,
+ ReplyTimeout: v.ReplyTimeout,
+ GroupTrigger: v.GroupTrigger,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, WeComSecurity{
+ Token: v.Token,
+ EncodingAESKey: v.EncodingAESKey,
+ }
+}
+
+type weixinConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"`
+ CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
+}
+
+func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) {
+ return WeixinConfig{
+ Enabled: v.Enabled,
+ token: v.Token,
+ BaseURL: v.BaseURL,
+ CDNBaseURL: v.CDNBaseURL,
+ Proxy: v.Proxy,
+ AllowFrom: v.AllowFrom,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, WeixinSecurity{
+ Token: v.Token,
+ }
+}
+
+type wecomappConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
+}
+
+func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) {
+ return WeComAppConfig{
+ Enabled: v.Enabled,
+ CorpID: v.CorpID,
+ corpSecret: v.CorpSecret,
+ AgentID: v.AgentID,
+ token: v.Token,
+ encodingAESKey: v.EncodingAESKey,
+ WebhookHost: v.WebhookHost,
+ WebhookPort: v.WebhookPort,
+ WebhookPath: v.WebhookPath,
+ AllowFrom: v.AllowFrom,
+ ReplyTimeout: v.ReplyTimeout,
+ GroupTrigger: v.GroupTrigger,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, WeComAppSecurity{
+ CorpSecret: v.CorpSecret,
+ Token: v.Token,
+ EncodingAESKey: v.EncodingAESKey,
+ }
+}
+
+type wecomaibotConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
+ Secret string `json:"secret" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
+ 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"`
+ WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
+}
+
+func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) {
+ return WeComAIBotConfig{
+ Enabled: v.Enabled,
+ WebhookPath: v.WebhookPath,
+ AllowFrom: v.AllowFrom,
+ ReplyTimeout: v.ReplyTimeout,
+ MaxSteps: v.MaxSteps,
+ WelcomeMessage: v.WelcomeMessage,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, WeComAIBotSecurity{
+ Token: v.Token,
+ Secret: v.Secret,
+ EncodingAESKey: v.EncodingAESKey,
+ }
+}
+
+type picoConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
+ AllowTokenQuery bool `json:"allow_token_query,omitempty"`
+ AllowOrigins []string `json:"allow_origins,omitempty"`
+ PingInterval int `json:"ping_interval,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ WriteTimeout int `json:"write_timeout,omitempty"`
+ MaxConnections int `json:"max_connections,omitempty"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+}
+
+func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) {
+ return PicoConfig{
+ Enabled: v.Enabled,
+ token: v.Token,
+ AllowTokenQuery: v.AllowTokenQuery,
+ AllowOrigins: v.AllowOrigins,
+ PingInterval: v.PingInterval,
+ ReadTimeout: v.ReadTimeout,
+ WriteTimeout: v.WriteTimeout,
+ MaxConnections: v.MaxConnections,
+ AllowFrom: v.AllowFrom,
+ Placeholder: v.Placeholder,
+ }, PicoSecurity{
+ Token: v.Token,
+ }
+}
+
+type ircConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
+ TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
+ Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
+ User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
+ RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
+ Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
+ NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
+ SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
+ SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+ Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
+ RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
+}
+
+func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) {
+ return IRCConfig{
+ Enabled: v.Enabled,
+ Server: v.Server,
+ TLS: v.TLS,
+ Nick: v.Nick,
+ User: v.User,
+ RealName: v.RealName,
+ password: v.Password,
+ nickServPassword: v.NickServPassword,
+ SASLUser: v.SASLUser,
+ saslPassword: v.SASLPassword,
+ Channels: v.Channels,
+ RequestCaps: v.RequestCaps,
+ AllowFrom: v.AllowFrom,
+ GroupTrigger: v.GroupTrigger,
+ Typing: v.Typing,
+ ReasoningChannelID: v.ReasoningChannelID,
+ }, IRCSecurity{
+ Password: v.Password,
+ NickServPassword: v.NickServPassword,
+ SASLPassword: v.SASLPassword,
+ }
+}
+
+type providersConfigV0 struct {
+ Anthropic providerConfigV0 `json:"anthropic"`
+ OpenAI openAIProviderConfigV0 `json:"openai"`
+ LiteLLM providerConfigV0 `json:"litellm"`
+ OpenRouter providerConfigV0 `json:"openrouter"`
+ Groq providerConfigV0 `json:"groq"`
+ Zhipu providerConfigV0 `json:"zhipu"`
+ VLLM providerConfigV0 `json:"vllm"`
+ Gemini providerConfigV0 `json:"gemini"`
+ Nvidia providerConfigV0 `json:"nvidia"`
+ Ollama providerConfigV0 `json:"ollama"`
+ Moonshot providerConfigV0 `json:"moonshot"`
+ ShengSuanYun providerConfigV0 `json:"shengsuanyun"`
+ DeepSeek providerConfigV0 `json:"deepseek"`
+ Cerebras providerConfigV0 `json:"cerebras"`
+ Vivgrid providerConfigV0 `json:"vivgrid"`
+ VolcEngine providerConfigV0 `json:"volcengine"`
+ GitHubCopilot providerConfigV0 `json:"github_copilot"`
+ Antigravity providerConfigV0 `json:"antigravity"`
+ Qwen providerConfigV0 `json:"qwen"`
+ Mistral providerConfigV0 `json:"mistral"`
+ Avian providerConfigV0 `json:"avian"`
+ Minimax providerConfigV0 `json:"minimax"`
+ LongCat providerConfigV0 `json:"longcat"`
+ ModelScope providerConfigV0 `json:"modelscope"`
+ Novita providerConfigV0 `json:"novita"`
+}
+
+// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
+// Note: WebSearch is an optimization option and doesn't count as "non-empty"
+func (p providersConfigV0) IsEmpty() bool {
+ return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
+ p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
+ p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
+ p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
+ p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
+ p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
+ p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
+ p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
+ p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
+ p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
+ p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
+ p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
+ p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
+ p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
+ p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
+ p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
+ p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
+ p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
+ p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
+ p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
+ p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
+ p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
+ p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
+ p.Novita.APIKey == "" && p.Novita.APIBase == ""
+}
+
+type providerConfigV0 struct {
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+}
+
+// MarshalJSON implements custom JSON marshaling for providersConfig
+// to omit the entire section when empty
+func (p providersConfigV0) MarshalJSON() ([]byte, error) {
+ if p.IsEmpty() {
+ return []byte("null"), nil
+ }
+ type Alias providersConfigV0
+ return json.Marshal((*Alias)(&p))
+}
+
+type openAIProviderConfigV0 struct {
+ providerConfigV0
+ WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
+}
+
+type modelConfigV0 struct {
+ // Required fields
+ ModelName string `json:"model_name"` // User-facing alias for the model
+ 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 (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
+ ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
+ Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
+
+ // Optional optimizations
+ RPM int `json:"rpm,omitempty"` // Requests per minute limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
+}
+
+func (c *configV0) migrateChannelConfigs() {
+ // Discord: mention_only -> group_trigger.mention_only
+ if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
+ c.Channels.Discord.GroupTrigger.MentionOnly = true
+ }
+
+ // OneBot: group_trigger_prefix -> group_trigger.prefixes
+ if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
+ len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
+ c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
+ }
+}
+
+func (c *configV0) Migrate() (*Config, error) {
+ // Migrate legacy channel config fields to new unified structures
+ cfg := DefaultConfig()
+
+ // Always copy user's Agents config to preserve settings like Provider, Model, MaxTokens
+ cfg.Agents.List = c.Agents.List
+ cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace
+ cfg.Agents.Defaults.RestrictToWorkspace = c.Agents.Defaults.RestrictToWorkspace
+ cfg.Agents.Defaults.AllowReadOutsideWorkspace = c.Agents.Defaults.AllowReadOutsideWorkspace
+ cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider
+ cfg.Agents.Defaults.ModelName = c.Agents.Defaults.GetModelName()
+ cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
+ cfg.Agents.Defaults.ImageModel = c.Agents.Defaults.ImageModel
+ cfg.Agents.Defaults.ImageModelFallbacks = c.Agents.Defaults.ImageModelFallbacks
+ cfg.Agents.Defaults.MaxTokens = c.Agents.Defaults.MaxTokens
+ cfg.Agents.Defaults.Temperature = c.Agents.Defaults.Temperature
+ cfg.Agents.Defaults.MaxToolIterations = c.Agents.Defaults.MaxToolIterations
+ cfg.Agents.Defaults.SummarizeMessageThreshold = c.Agents.Defaults.SummarizeMessageThreshold
+ cfg.Agents.Defaults.SummarizeTokenPercent = c.Agents.Defaults.SummarizeTokenPercent
+ cfg.Agents.Defaults.MaxMediaSize = c.Agents.Defaults.MaxMediaSize
+ cfg.Agents.Defaults.Routing = c.Agents.Defaults.Routing
+
+ // Copy other top-level fields
+ cfg.Bindings = c.Bindings
+ cfg.Session = c.Session
+ var secChannels ChannelsSecurity
+ cfg.Channels, secChannels = c.Channels.ToChannelsConfig()
+ cfg.Gateway = c.Gateway
+ var secWeb WebToolsSecurity
+ cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
+ cfg.Tools.Cron = c.Tools.Cron
+ cfg.Tools.Exec = c.Tools.Exec
+ var secSkills SkillsSecurity
+ cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
+ cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
+ cfg.Tools.MCP = c.Tools.MCP
+ cfg.Tools.AppendFile = c.Tools.AppendFile
+ cfg.Tools.EditFile = c.Tools.EditFile
+ cfg.Tools.FindSkills = c.Tools.FindSkills
+ cfg.Tools.I2C = c.Tools.I2C
+ cfg.Tools.InstallSkill = c.Tools.InstallSkill
+ cfg.Tools.ListDir = c.Tools.ListDir
+ cfg.Tools.Message = c.Tools.Message
+ cfg.Tools.ReadFile = c.Tools.ReadFile
+ cfg.Tools.SendFile = c.Tools.SendFile
+ cfg.Tools.Spawn = c.Tools.Spawn
+ cfg.Tools.SpawnStatus = c.Tools.SpawnStatus
+ cfg.Tools.SPI = c.Tools.SPI
+ cfg.Tools.Subagent = c.Tools.Subagent
+ cfg.Tools.WebFetch = c.Tools.WebFetch
+ cfg.Tools.AllowReadPaths = c.Tools.AllowReadPaths
+ cfg.Tools.AllowWritePaths = c.Tools.AllowWritePaths
+ cfg.Heartbeat = c.Heartbeat
+ cfg.Devices = c.Devices
+
+ secModels := make(map[string]ModelSecurityEntry, 0)
+ // Only override ModelList if user provided values
+ if len(c.ModelList) > 0 {
+ // Convert []modelConfigV0 to []ModelConfig
+ cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
+ for i, m := range c.ModelList {
+ // Merge APIKey and APIKeys, deduplicating
+ mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
+
+ cfg.ModelList[i] = &ModelConfig{
+ ModelName: m.ModelName,
+ Model: m.Model,
+ APIBase: m.APIBase,
+ Proxy: m.Proxy,
+ Fallbacks: m.Fallbacks,
+ AuthMethod: m.AuthMethod,
+ ConnectMode: m.ConnectMode,
+ Workspace: m.Workspace,
+ RPM: m.RPM,
+ MaxTokensField: m.MaxTokensField,
+ RequestTimeout: m.RequestTimeout,
+ ThinkingLevel: m.ThinkingLevel,
+ apiKeys: mergedKeys,
+ }
+ }
+ names := toNameIndex(cfg.ModelList)
+ for i, m := range c.ModelList {
+ // Merge APIKey and APIKeys, deduplicating
+ mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
+ secModels[names[i]] = ModelSecurityEntry{
+ APIKeys: mergedKeys,
+ }
+ }
+ }
+
+ cfg.WithSecurity(&SecurityConfig{
+ ModelList: secModels,
+ Channels: secChannels,
+ Web: secWeb,
+ Skills: secSkills,
+ })
+ cfg.Version = CurrentVersion
+ return cfg, nil
+}
+
+type webToolsConfigV0 struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
+ Brave braveConfigV0 ` json:"brave"`
+ Tavily tavilyConfigV0 ` json:"tavily"`
+ DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
+ Perplexity perplexityConfigV0 ` json:"perplexity"`
+ SearXNG SearXNGConfig ` json:"searxng"`
+ GLMSearch glmSearchConfigV0 ` json:"glm_search"`
+ PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
+ Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
+ FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
+ Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
+ PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
+}
+
+type braveConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
+}
+
+func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) {
+ return BraveConfig{
+ Enabled: v.Enabled,
+ MaxResults: v.MaxResults,
+ }, BraveSecurity{
+ APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
+ }
+}
+
+type tavilyConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
+func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) {
+ return TavilyConfig{
+ Enabled: v.Enabled,
+ BaseURL: v.BaseURL,
+ MaxResults: v.MaxResults,
+ }, TavilySecurity{
+ APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
+ }
+}
+
+type perplexityConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
+func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) {
+ return PerplexityConfig{
+ Enabled: v.Enabled,
+ MaxResults: v.MaxResults,
+ }, PerplexitySecurity{
+ APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
+ }
+}
+
+type glmSearchConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
+ SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
+}
+
+func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) {
+ return GLMSearchConfig{
+ Enabled: v.Enabled,
+ apiKey: v.APIKey,
+ BaseURL: v.BaseURL,
+ SearchEngine: v.SearchEngine,
+ }, GLMSearchSecurity{
+ APIKey: v.APIKey,
+ }
+}
+
+func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
+ brave, braveSecurity := v.Brave.ToBraveConfig()
+ tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
+ perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
+ glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
+
+ return WebToolsConfig{
+ ToolConfig: v.ToolConfig,
+ Brave: brave,
+ Tavily: tavily,
+ DuckDuckGo: v.DuckDuckGo,
+ Perplexity: perplexity,
+ SearXNG: v.SearXNG,
+ GLMSearch: glmSearch,
+ PreferNative: v.PreferNative,
+ Proxy: v.Proxy,
+ FetchLimitBytes: v.FetchLimitBytes,
+ Format: v.Format,
+ PrivateHostWhitelist: v.PrivateHostWhitelist,
+ }, WebToolsSecurity{
+ Brave: &braveSecurity,
+ Tavily: &tavilySecurity,
+ Perplexity: &perplexitySecurity,
+ GLMSearch: &glmSearchSecurity,
+ }
+}
+
+type skillsToolsConfigV0 struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
+ Registries skillsRegistriesConfigV0 ` json:"registries"`
+ Github skillsGithubConfigV0 ` json:"github"`
+ MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
+ SearchCache SearchCacheConfig ` json:"search_cache"`
+}
+
+type skillsRegistriesConfigV0 struct {
+ ClawHub clawHubRegistryConfigV0 `json:"clawhub"`
+}
+
+type clawHubRegistryConfigV0 struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
+ AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
+ SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
+}
+
+func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) {
+ return ClawHubRegistryConfig{
+ Enabled: v.Enabled,
+ BaseURL: v.BaseURL,
+ authToken: v.AuthToken,
+ SearchPath: v.SearchPath,
+ SkillsPath: v.SkillsPath,
+ }, ClawHubSecurity{
+ AuthToken: v.AuthToken,
+ }
+}
+
+type skillsGithubConfigV0 struct {
+ Token string `json:"token" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
+}
+
+func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) {
+ return SkillsGithubConfig{
+ token: v.Token,
+ Proxy: v.Proxy,
+ }, GithubSecurity{
+ Token: v.Token,
+ }
+}
+
+func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
+ clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig()
+
+ return SkillsRegistriesConfig{
+ ClawHub: clawHub,
+ }, &clawHubSecurity
+}
+
+func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) {
+ registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
+ github, githubSecurity := v.Github.ToSkillsGithubConfig()
+
+ return SkillsToolsConfig{
+ ToolConfig: v.ToolConfig,
+ Registries: registries,
+ Github: github,
+ MaxConcurrentSearches: v.MaxConcurrentSearches,
+ SearchCache: v.SearchCache,
+ }, SkillsSecurity{
+ Github: &githubSecurity,
+ ClawHub: registriesSecurity,
+ }
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 88ab1ed51..3f8ec6150 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -8,6 +8,9 @@ import (
"strings"
"testing"
+ "github.com/stretchr/testify/assert"
+ "gopkg.in/yaml.v3"
+
"github.com/sipeed/picoclaw/pkg/credential"
)
@@ -78,18 +81,19 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
}
func TestProvidersConfig_IsEmpty(t *testing.T) {
- var empty ProvidersConfig
+ var empty providersConfigV0
+ t.Logf("empty: %+v", empty)
if !empty.IsEmpty() {
- t.Fatal("empty ProvidersConfig should report empty")
+ t.Fatal("empty providersConfig should report empty")
}
- novita := ProvidersConfig{
- Novita: ProviderConfig{
+ novita := providersConfigV0{
+ Novita: providerConfigV0{
APIKey: "test-key",
},
}
if novita.IsEmpty() {
- t.Fatal("ProvidersConfig with novita settings should not report empty")
+ t.Fatal("providersConfig with novita settings should not report empty")
}
}
@@ -237,15 +241,6 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
}
}
-// TestDefaultConfig_Model verifies model is set
-func TestDefaultConfig_Model(t *testing.T) {
- cfg := DefaultConfig()
-
- if cfg.Agents.Defaults.Model != "" {
- t.Error("Model should be empty")
- }
-}
-
// TestDefaultConfig_MaxTokens verifies max tokens has default value
func TestDefaultConfig_MaxTokens(t *testing.T) {
cfg := DefaultConfig()
@@ -288,21 +283,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
}
}
-// TestDefaultConfig_Providers verifies provider structure
-func TestDefaultConfig_Providers(t *testing.T) {
- cfg := DefaultConfig()
-
- if cfg.Providers.Anthropic.APIKey != "" {
- t.Error("Anthropic API key should be empty by default")
- }
- if cfg.Providers.OpenAI.APIKey != "" {
- t.Error("OpenAI API key should be empty by default")
- }
- if cfg.Providers.OpenRouter.APIKey != "" {
- t.Error("OpenRouter API key should be empty by default")
- }
-}
-
// TestDefaultConfig_Channels verifies channels are disabled by default
func TestDefaultConfig_Channels(t *testing.T) {
cfg := DefaultConfig()
@@ -329,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
if cfg.Tools.Web.Brave.MaxResults != 5 {
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
}
- if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
+ if len(cfg.Tools.Web.Brave.APIKeys()) != 0 {
t.Error("Brave API key should be empty by default")
}
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
@@ -387,9 +367,6 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
- if cfg.Agents.Defaults.Model != "" {
- t.Error("Model should be empty")
- }
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")
}
@@ -408,12 +385,8 @@ func TestConfig_Complete(t *testing.T) {
if !cfg.Heartbeat.Enabled {
t.Error("Heartbeat should be enabled by default")
}
-}
-
-func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
- cfg := DefaultConfig()
- if !cfg.Providers.OpenAI.WebSearch {
- t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
+ if !cfg.Tools.Exec.AllowRemote {
+ t.Error("Exec.AllowRemote should be true by default")
}
}
@@ -427,7 +400,7 @@ func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
+ if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
@@ -488,31 +461,16 @@ func TestDefaultConfig_HooksDefaults(t *testing.T) {
func TestDefaultConfig_LogLevel(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Agents.Defaults.LogLevel != "fatal" {
- t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
- }
-}
-
-func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
- dir := t.TempDir()
- configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
- t.Fatalf("WriteFile() error: %v", err)
- }
-
- cfg, err := LoadConfig(configPath)
- if err != nil {
- t.Fatalf("LoadConfig() error: %v", err)
- }
- if !cfg.Providers.OpenAI.WebSearch {
- t.Fatal("OpenAI codex web search should remain true when unset in config file")
+ if cfg.Gateway.LogLevel != "fatal" {
+ t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
}
}
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
+ if err := os.WriteFile(configPath, []byte(`{"version":1,"tools":{"exec":{"enable_deny_patterns":true}}}`),
+ 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
@@ -528,7 +486,11 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil {
+ if err := os.WriteFile(
+ configPath,
+ []byte(`{"version":1,"tools":{"cron":{"exec_timeout_minutes":5}}}`),
+ 0o600,
+ ); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
@@ -541,22 +503,6 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
}
}
-func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
- dir := t.TempDir()
- configPath := filepath.Join(dir, "config.json")
- if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
- t.Fatalf("WriteFile() error: %v", err)
- }
-
- cfg, err := LoadConfig(configPath)
- if err != nil {
- t.Fatalf("LoadConfig() error: %v", err)
- }
- if cfg.Providers.OpenAI.WebSearch {
- t.Fatal("OpenAI codex web search should be false when disabled in config file")
- }
-}
-
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
@@ -582,6 +528,7 @@ func TestLoadConfig_HooksProcessConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{
+ "version": 1,
"hooks": {
"processes": {
"review-gate": {
@@ -834,7 +781,20 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
- const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
+ const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
+ if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+ secPath := filepath.Join(dir, SecurityConfigFile)
+ const securityConfig = `
+model_list:
+ test:0:
+ api_keys:
+ - "sk-plaintext"
+`
+ if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
@@ -847,10 +807,10 @@ func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
t.Fatalf("LoadConfig: %v", err)
}
// In-memory value must be the resolved plaintext.
- if cfg.ModelList[0].APIKey != "sk-plaintext" {
- t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext")
+ if cfg.ModelList[0].APIKey() != "sk-plaintext" {
+ t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey(), "sk-plaintext")
}
- // The file on disk must remain unchanged — LoadConfig must not write anything.
+ // The file on disk must remain unchanged — no need upgrade version
raw, _ := os.ReadFile(cfgPath)
if string(raw) != original {
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
@@ -867,15 +827,19 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
mustSetupSSHKey(t)
cfg := DefaultConfig()
- cfg.ModelList = []ModelConfig{
- {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
+ cfg.ModelList = []*ModelConfig{
+ {ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}},
+ }
+ cfg.security = &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}},
}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
// Disk must contain enc://, not the raw key.
- raw, _ := os.ReadFile(cfgPath)
+ secPath := filepath.Join(dir, SecurityConfigFile)
+ raw, _ := os.ReadFile(secPath)
if !strings.Contains(string(raw), "enc://") {
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
}
@@ -888,8 +852,8 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig after SaveConfig: %v", err)
}
- if cfg2.ModelList[0].APIKey != "sk-plaintext" {
- t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext")
+ if cfg2.ModelList[0].APIKey() != "sk-plaintext" {
+ t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey(), "sk-plaintext")
}
}
@@ -925,10 +889,17 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
- data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}`
+ data := `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4"}]}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
+ secPath := filepath.Join(dir, SecurityConfigFile)
+ if err := saveSecurityConfig(
+ secPath,
+ &SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}},
+ ); err != nil {
+ t.Fatalf("saveSecurityConfig: %v", err)
+ }
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
@@ -937,7 +908,7 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
t.Fatalf("LoadConfig: %v", err)
}
- raw, _ := os.ReadFile(cfgPath)
+ raw, _ := os.ReadFile(secPath)
if !strings.Contains(string(raw), "file://openai.key") {
t.Error("file:// reference should be preserved unchanged in the config file")
}
@@ -957,23 +928,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
if err := SaveConfig(cfgPath, &Config{
- ModelList: []ModelConfig{
- {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"},
+ ModelList: []*ModelConfig{
+ {ModelName: "pre", Model: "openai/gpt-4"},
+ },
+ security: &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "pre:0": {APIKeys: []string{"sk-already-plain"}},
+ },
},
}); err != nil {
t.Fatalf("setup SaveConfig: %v", err)
}
- raw, _ := os.ReadFile(cfgPath)
+ raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
// Extract the enc:// value from the saved file.
var tmp struct {
- ModelList []struct {
- APIKey string `json:"api_key"`
- } `json:"model_list"`
+ ModelList map[string]struct {
+ APIKeys []string `yaml:"api_keys"`
+ } `yaml:"model_list"`
}
- if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
+ if err := yaml.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
t.Fatalf("setup: could not parse saved config: %v", err)
}
- alreadyEncrypted := tmp.ModelList[0].APIKey
+ alreadyEncrypted := tmp.ModelList["pre:0"].APIKeys[0]
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
}
@@ -987,19 +963,28 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
t.Fatalf("setup: %v", err)
}
cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"},
- {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted},
- {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"},
+ ModelList: []*ModelConfig{
+ {ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}},
+ {ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}},
+ {ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}},
+ },
+ security: &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "plain:0": {APIKeys: []string{"sk-new-plaintext"}},
+ "enc:0": {APIKeys: []string{alreadyEncrypted}},
+ "file:0": {APIKeys: []string{"file://api.key"}},
+ },
},
}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
- raw, _ = os.ReadFile(cfgPath)
+ raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
s := string(raw)
+ t.Logf("saved file:\n%s", s)
+
// 1. Plaintext must be encrypted.
if strings.Contains(s, "sk-new-plaintext") {
t.Error("plaintext key must not appear in saved file")
@@ -1020,7 +1005,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
}
byName := make(map[string]string)
for _, m := range cfg2.ModelList {
- byName[m.ModelName] = m.APIKey
+ byName[m.ModelName] = m.APIKey()
}
if byName["plain"] != "sk-new-plaintext" {
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
@@ -1044,26 +1029,26 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
mustSetupSSHKey(t)
if err := SaveConfig(cfgPath, &Config{
- ModelList: []ModelConfig{
- {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"},
+ ModelList: []*ModelConfig{
+ {ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}},
+ },
+ security: &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "m:0": {APIKeys: []string{"sk-secret"}},
+ },
},
}); err != nil {
t.Fatalf("setup SaveConfig: %v", err)
}
- raw, _ := os.ReadFile(cfgPath)
- var tmp struct {
- ModelList []struct {
- APIKey string `json:"api_key"`
- } `json:"model_list"`
- }
- if err := json.Unmarshal(raw, &tmp); err != nil {
- t.Fatalf("setup parse: %v", err)
- }
- encValue := tmp.ModelList[0].APIKey
+ raw, err := LoadConfig(cfgPath)
+ assert.NoError(t, err)
+ encValue := raw.security.ModelList["m:0"].APIKeys[0]
+ assert.NotEmpty(t, encValue)
+ assert.Equal(t, "enc://", encValue[:6])
// Write a mixed config: enc:// + plaintext + file://
keyFile := filepath.Join(dir, "api.key")
- if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
+ if err = os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
mixed, _ := json.Marshal(map[string]any{
@@ -1073,14 +1058,24 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
},
})
- if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {
+ if err = os.WriteFile(cfgPath, mixed, 0o600); err != nil {
t.Fatalf("setup write: %v", err)
}
+ secs, _ := yaml.Marshal(map[string]any{
+ "model_list": map[string]map[string]any{
+ "enc:0": {"api_keys": []string{encValue}},
+ "plain:0": {"api_keys": []string{"sk-plain"}},
+ "file:0": {"api_keys": []string{"file://api.key"}},
+ },
+ })
+ if err = os.WriteFile(filepath.Join(dir, SecurityConfigFile), secs, 0o600); err != nil {
+ t.Fatalf("security write: %v", err)
+ }
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
- _, err := LoadConfig(cfgPath)
+ _, err = LoadConfig(cfgPath)
if err == nil {
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
}
@@ -1108,14 +1103,15 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
t.Cleanup(func() { credential.PassphraseProvider = orig })
cfg := DefaultConfig()
- cfg.ModelList = []ModelConfig{
- {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
+ cfg.ModelList = []*ModelConfig{
+ {ModelName: "test", Model: "openai/gpt-4"},
}
+ cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
- raw, _ := os.ReadFile(cfgPath)
+ raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
if !strings.Contains(string(raw), "enc://") {
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
}
@@ -1158,15 +1154,15 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
- if cfg.ModelList[0].APIKey != plainKey {
- t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey)
+ if cfg.ModelList[0].APIKey() != plainKey {
+ t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey(), plainKey)
}
}
func TestConfigParsesLogLevel(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
- data := `{"agents":{"defaults":{"log_level":"debug"}}}`
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
@@ -1175,15 +1171,15 @@ func TestConfigParsesLogLevel(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
- if cfg.Agents.Defaults.LogLevel != "debug" {
- t.Errorf("LogLevel = %q, want \"debug\"", cfg.Agents.Defaults.LogLevel)
+ if cfg.Gateway.LogLevel != "debug" {
+ t.Errorf("LogLevel = %q, want \"debug\"", cfg.Gateway.LogLevel)
}
}
func TestConfigLogLevelEmpty(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
- data := `{}`
+ data := `{"version":1}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
@@ -1193,7 +1189,66 @@ func TestConfigLogLevelEmpty(t *testing.T) {
t.Fatalf("LoadConfig: %v", err)
}
// When config omits log_level, the DefaultConfig value ("fatal") is preserved.
- if cfg.Agents.Defaults.LogLevel != "fatal" {
- t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
+ if cfg.Gateway.LogLevel != "fatal" {
+ t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
+ }
+}
+
+func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+
+ cfg := &Config{
+ ModelList: []*ModelConfig{
+ {
+ ModelName: "test-model",
+ Model: "openai/test",
+ apiKeys: []string{"sk-test"},
+ ExtraBody: map[string]any{"custom_field": "value", "num_field": 42},
+ },
+ },
+ security: &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{"test-model:0": {APIKeys: []string{"sk-test"}}},
+ },
+ }
+
+ if err := SaveConfig(cfgPath, cfg); err != nil {
+ t.Fatalf("SaveConfig error: %v", err)
+ }
+
+ loaded, err := LoadConfig(cfgPath)
+ if err != nil {
+ t.Fatalf("LoadConfig error: %v", err)
+ }
+
+ if loaded.ModelList[0].ExtraBody == nil {
+ t.Fatal("ExtraBody should not be nil after round-trip")
+ }
+ if got := loaded.ModelList[0].ExtraBody["custom_field"]; got != "value" {
+ t.Errorf("ExtraBody[custom_field] = %v, want value", got)
+ }
+ if got := loaded.ModelList[0].ExtraBody["num_field"]; got != float64(42) {
+ t.Errorf("ExtraBody[num_field] = %v, want 42", got)
+ }
+}
+
+func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
+ cfg := DefaultConfig()
+
+ var minimaxCfg *ModelConfig
+ for i := range cfg.ModelList {
+ if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" {
+ minimaxCfg = cfg.ModelList[i]
+ break
+ }
+ }
+ if minimaxCfg == nil {
+ t.Fatal("Minimax model not found in ModelList")
+ }
+ if minimaxCfg.ExtraBody == nil {
+ t.Fatal("Minimax ExtraBody should not be nil")
+ }
+ if got, ok := minimaxCfg.ExtraBody["reasoning_split"]; !ok || got != true {
+ t.Fatalf("Minimax ExtraBody[reasoning_split] = %v, want true", got)
}
}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 3397eb91c..ccfd5732a 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -8,6 +8,8 @@ package config
import (
"os"
"path/filepath"
+
+ "github.com/sipeed/picoclaw/pkg"
)
// DefaultConfig returns the default configuration for PicoClaw.
@@ -19,18 +21,17 @@ func DefaultConfig() *Config {
homePath = picoclawHome
} else {
userHome, _ := os.UserHomeDir()
- homePath = filepath.Join(userHome, ".picoclaw")
+ homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome)
}
- workspacePath := filepath.Join(homePath, "workspace")
+ workspacePath := filepath.Join(homePath, pkg.WorkspaceName)
return &Config{
+ Version: CurrentVersion,
Agents: AgentsConfig{
Defaults: AgentDefaults{
- LogLevel: "fatal",
Workspace: workspacePath,
RestrictToWorkspace: true,
Provider: "",
- Model: "",
MaxTokens: 32768,
Temperature: nil, // nil means use provider default
MaxToolIterations: 50,
@@ -57,7 +58,6 @@ func DefaultConfig() *Config {
},
Telegram: TelegramConfig{
Enabled: false,
- Token: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{
@@ -68,16 +68,12 @@ func DefaultConfig() *Config {
UseMarkdownV2: false,
},
Feishu: FeishuConfig{
- Enabled: false,
- AppID: "",
- AppSecret: "",
- EncryptKey: "",
- VerificationToken: "",
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ AppID: "",
+ AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{
Enabled: false,
- Token: "",
AllowFrom: FlexibleStringSlice{},
MentionOnly: false,
},
@@ -90,28 +86,23 @@ func DefaultConfig() *Config {
QQ: QQConfig{
Enabled: false,
AppID: "",
- AppSecret: "",
AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
MaxBase64FileSizeMiB: 0,
},
DingTalk: DingTalkConfig{
- Enabled: false,
- ClientID: "",
- ClientSecret: "",
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ ClientID: "",
+ AllowFrom: FlexibleStringSlice{},
},
Slack: SlackConfig{
Enabled: false,
- BotToken: "",
- AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
Matrix: MatrixConfig{
Enabled: false,
Homeserver: "https://matrix.org",
UserID: "",
- AccessToken: "",
DeviceID: "",
JoinOnInvite: true,
AllowFrom: FlexibleStringSlice{},
@@ -124,51 +115,40 @@ func DefaultConfig() *Config {
},
},
LINE: LINEConfig{
- Enabled: false,
- ChannelSecret: "",
- ChannelAccessToken: "",
- WebhookHost: "0.0.0.0",
- WebhookPort: 18791,
- WebhookPath: "/webhook/line",
- AllowFrom: FlexibleStringSlice{},
- GroupTrigger: GroupTriggerConfig{MentionOnly: true},
+ Enabled: false,
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18791,
+ WebhookPath: "/webhook/line",
+ AllowFrom: FlexibleStringSlice{},
+ GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
- Enabled: false,
- WSUrl: "ws://127.0.0.1:3001",
- AccessToken: "",
- ReconnectInterval: 5,
- GroupTriggerPrefix: []string{},
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ WSUrl: "ws://127.0.0.1:3001",
+ ReconnectInterval: 5,
+ AllowFrom: FlexibleStringSlice{},
},
WeCom: WeComConfig{
- Enabled: false,
- Token: "",
- EncodingAESKey: "",
- WebhookURL: "",
- WebhookHost: "0.0.0.0",
- WebhookPort: 18793,
- WebhookPath: "/webhook/wecom",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
+ Enabled: false,
+ WebhookURL: "",
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18793,
+ WebhookPath: "/webhook/wecom",
+ AllowFrom: FlexibleStringSlice{},
+ ReplyTimeout: 5,
},
WeComApp: WeComAppConfig{
- Enabled: false,
- CorpID: "",
- CorpSecret: "",
- AgentID: 0,
- Token: "",
- EncodingAESKey: "",
- WebhookHost: "0.0.0.0",
- WebhookPort: 18792,
- WebhookPath: "/webhook/wecom-app",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
+ Enabled: false,
+ CorpID: "",
+ AgentID: 0,
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18792,
+ WebhookPath: "/webhook/wecom-app",
+ AllowFrom: FlexibleStringSlice{},
+ ReplyTimeout: 5,
},
WeComAIBot: WeComAIBotConfig{
Enabled: false,
- Token: "",
- EncodingAESKey: "",
WebhookPath: "/webhook/wecom-aibot",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
@@ -178,7 +158,6 @@ func DefaultConfig() *Config {
},
Weixin: WeixinConfig{
Enabled: false,
- Token: "",
BaseURL: "https://ilinkai.weixin.qq.com/",
CDNBaseURL: "https://novac2c.cdn.weixin.qq.com/c2c",
AllowFrom: FlexibleStringSlice{},
@@ -186,7 +165,6 @@ func DefaultConfig() *Config {
},
Pico: PicoConfig{
Enabled: false,
- Token: "",
PingInterval: 30,
ReadTimeout: 60,
WriteTimeout: 10,
@@ -202,10 +180,7 @@ func DefaultConfig() *Config {
ApprovalTimeoutMS: 60000,
},
},
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{WebSearch: true},
- },
- ModelList: []ModelConfig{
+ ModelList: []*ModelConfig{
// ============================================
// Add your API key to the model you want to use
// ============================================
@@ -215,7 +190,6 @@ func DefaultConfig() *Config {
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://open.bigmodel.cn/api/paas/v4",
- APIKey: "",
},
// OpenAI - https://platform.openai.com/api-keys
@@ -223,7 +197,6 @@ func DefaultConfig() *Config {
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1",
- APIKey: "",
},
// Anthropic Claude - https://console.anthropic.com/settings/keys
@@ -231,7 +204,6 @@ func DefaultConfig() *Config {
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
APIBase: "https://api.anthropic.com/v1",
- APIKey: "",
},
// DeepSeek - https://platform.deepseek.com/
@@ -239,7 +211,6 @@ func DefaultConfig() *Config {
ModelName: "deepseek-chat",
Model: "deepseek/deepseek-chat",
APIBase: "https://api.deepseek.com/v1",
- APIKey: "",
},
// Google Gemini - https://ai.google.dev/
@@ -247,7 +218,6 @@ func DefaultConfig() *Config {
ModelName: "gemini-2.0-flash",
Model: "gemini/gemini-2.0-flash-exp",
APIBase: "https://generativelanguage.googleapis.com/v1beta",
- APIKey: "",
},
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
@@ -255,7 +225,6 @@ func DefaultConfig() *Config {
ModelName: "qwen-plus",
Model: "qwen/qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
- APIKey: "",
},
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
@@ -263,7 +232,6 @@ func DefaultConfig() *Config {
ModelName: "moonshot-v1-8k",
Model: "moonshot/moonshot-v1-8k",
APIBase: "https://api.moonshot.cn/v1",
- APIKey: "",
},
// Groq - https://console.groq.com/keys
@@ -271,7 +239,6 @@ func DefaultConfig() *Config {
ModelName: "llama-3.3-70b",
Model: "groq/llama-3.3-70b-versatile",
APIBase: "https://api.groq.com/openai/v1",
- APIKey: "",
},
// OpenRouter (100+ models) - https://openrouter.ai/keys
@@ -279,13 +246,11 @@ func DefaultConfig() *Config {
ModelName: "openrouter-auto",
Model: "openrouter/auto",
APIBase: "https://openrouter.ai/api/v1",
- APIKey: "",
},
{
ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1",
- APIKey: "",
},
// NVIDIA - https://build.nvidia.com/
@@ -293,7 +258,6 @@ func DefaultConfig() *Config {
ModelName: "nemotron-4-340b",
Model: "nvidia/nemotron-4-340b-instruct",
APIBase: "https://integrate.api.nvidia.com/v1",
- APIKey: "",
},
// Cerebras - https://inference.cerebras.ai/
@@ -301,7 +265,6 @@ func DefaultConfig() *Config {
ModelName: "cerebras-llama-3.3-70b",
Model: "cerebras/llama-3.3-70b",
APIBase: "https://api.cerebras.ai/v1",
- APIKey: "",
},
// Vivgrid - https://vivgrid.com
@@ -309,7 +272,6 @@ func DefaultConfig() *Config {
ModelName: "vivgrid-auto",
Model: "vivgrid/auto",
APIBase: "https://api.vivgrid.com/v1",
- APIKey: "",
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
@@ -317,13 +279,11 @@ func DefaultConfig() *Config {
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
- APIKey: "",
},
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
- APIKey: "",
},
// ShengsuanYun (神算云)
@@ -331,7 +291,6 @@ func DefaultConfig() *Config {
ModelName: "deepseek-v3",
Model: "shengsuanyun/deepseek-v3",
APIBase: "https://api.shengsuanyun.com/v1",
- APIKey: "",
},
// Antigravity (Google Cloud Code Assist) - OAuth only
@@ -354,7 +313,6 @@ func DefaultConfig() *Config {
ModelName: "llama3",
Model: "ollama/llama3",
APIBase: "http://localhost:11434/v1",
- APIKey: "ollama",
},
// Mistral AI - https://console.mistral.ai/api-keys
@@ -362,7 +320,6 @@ func DefaultConfig() *Config {
ModelName: "mistral-small",
Model: "mistral/mistral-small-latest",
APIBase: "https://api.mistral.ai/v1",
- APIKey: "",
},
// Avian - https://avian.io
@@ -370,13 +327,11 @@ func DefaultConfig() *Config {
ModelName: "deepseek-v3.2",
Model: "avian/deepseek/deepseek-v3.2",
APIBase: "https://api.avian.io/v1",
- APIKey: "",
},
{
ModelName: "kimi-k2.5",
Model: "avian/moonshotai/kimi-k2.5",
APIBase: "https://api.avian.io/v1",
- APIKey: "",
},
// Minimax - https://api.minimaxi.com/
@@ -384,7 +339,7 @@ func DefaultConfig() *Config {
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
- APIKey: "",
+ ExtraBody: map[string]any{"reasoning_split": true},
},
// LongCat - https://longcat.chat/platform
@@ -392,7 +347,6 @@ func DefaultConfig() *Config {
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
- APIKey: "",
},
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
@@ -400,7 +354,6 @@ func DefaultConfig() *Config {
ModelName: "modelscope-qwen",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIBase: "https://api-inference.modelscope.cn/v1",
- APIKey: "",
},
// VLLM (local) - http://localhost:8000
@@ -408,7 +361,6 @@ func DefaultConfig() *Config {
ModelName: "local-model",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
- APIKey: "",
},
// Azure OpenAI - https://portal.azure.com
@@ -417,13 +369,13 @@ func DefaultConfig() *Config {
ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment",
APIBase: "https://your-resource.openai.azure.com",
- APIKey: "",
},
},
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
+ LogLevel: "fatal",
},
Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{
@@ -443,14 +395,10 @@ func DefaultConfig() *Config {
Format: "plaintext",
Brave: BraveConfig{
Enabled: false,
- APIKey: "",
- APIKeys: nil,
MaxResults: 5,
},
Tavily: TavilyConfig{
Enabled: false,
- APIKey: "",
- APIKeys: nil,
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
@@ -459,8 +407,6 @@ func DefaultConfig() *Config {
},
Perplexity: PerplexityConfig{
Enabled: false,
- APIKey: "",
- APIKeys: nil,
MaxResults: 5,
},
SearXNG: SearXNGConfig{
@@ -470,11 +416,15 @@ func DefaultConfig() *Config {
},
GLMSearch: GLMSearchConfig{
Enabled: false,
- APIKey: "",
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
SearchEngine: "search_std",
MaxResults: 5,
},
+ BaiduSearch: BaiduSearchConfig{
+ Enabled: false,
+ BaseURL: "https://qianfan.baidubce.com/v2/ai_search/web_search",
+ MaxResults: 10,
+ },
},
Cron: CronToolsConfig{
ToolConfig: ToolConfig{
@@ -576,6 +526,7 @@ func DefaultConfig() *Config {
MonitorUSB: true,
},
Voice: VoiceConfig{
+ ModelName: "",
EchoTranscription: false,
},
BuildInfo: BuildInfo{
@@ -584,5 +535,10 @@ func DefaultConfig() *Config {
BuildTime: BuildTime,
GoVersion: GoVersion,
},
+ security: &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{},
+ Channels: ChannelsSecurity{},
+ Web: WebToolsSecurity{},
+ },
}
}
diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go
new file mode 100644
index 000000000..cba76c6bc
--- /dev/null
+++ b/pkg/config/example_security_usage.go
@@ -0,0 +1,423 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// This file demonstrates how to use the security configuration feature
+// It's not meant to be compiled, just for documentation purposes
+
+/*
+Package config
+
+# Example: Using Security Configuration
+
+## 1. Create security.yml
+
+File: ~/.picoclaw/security.yml
+
+```yaml
+# Model API Keys
+# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
+# Single key should be provided as an array with one element
+model_list:
+
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-your-actual-openai-key-1"
+ - "sk-proj-your-actual-openai-key-2" # Failover key
+ claude-sonnet-4.6:
+ api_keys:
+ - "sk-ant-your-actual-anthropic-key" # Single key in array format
+
+# Channel Tokens
+channels:
+
+ telegram:
+ token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
+ discord:
+ token: "your-discord-bot-token"
+
+# Web Tool Keys
+# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
+# For GLMSearch, use 'api_key' (single string)
+web:
+
+ brave:
+ api_keys:
+ - "BSAyour-brave-api-key-1"
+ - "BSAyour-brave-api-key-2" # Failover key
+ tavily:
+ api_keys:
+ - "tvly-your-tavily-api-key" # Single key in array format
+ glm_search:
+ api_key: "your-glm-search-api-key" # Single key (not array)
+
+```
+
+## 2. Update config.json to use references
+
+File: ~/.picoclaw/config.json
+
+```json
+
+ {
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/picoclaw-workspace",
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api.openai.com/v1",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_base": "https://api.anthropic.com/v1",
+ "api_key": "ref:model_list.claude-sonnet-4.6.api_key"
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "ref:channels.telegram.token"
+ },
+ "discord": {
+ "enabled": true,
+ "token": "ref:channels.discord.token"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "ref:web.brave.api_key"
+ },
+ "tavily": {
+ "enabled": true,
+ "api_key": "ref:web.tavily.api_key"
+ }
+ }
+ }
+ }
+
+```
+
+## 3. Set proper permissions
+
+```bash
+chmod 600 ~/.picoclaw/security.yml
+```
+
+## 4. Add to .gitignore
+
+```gitignore
+# Security configuration
+.security.yml
+```
+
+## 5. Verify it works
+
+```bash
+picoclaw --version
+```
+
+# Available Reference Paths
+
+## Model API Keys
+- ref:model_list..api_key
+
+Examples:
+- ref:model_list.gpt-5.4.api_key
+- ref:model_list.claude-sonnet-4.6.api_key
+
+**Note:** In .security.yml, use `api_keys` (array) format for models.
+Both single and multiple keys should use the array format.
+
+## Channel Tokens/Secrets
+- ref:channels.telegram.token
+- ref:channels.feishu.app_secret
+- ref:channels.feishu.encrypt_key
+- ref:channels.feishu.verification_token
+- ref:channels.discord.token
+- ref:channels.qq.app_secret
+- ref:channels.dingtalk.client_secret
+- ref:channels.slack.bot_token
+- ref:channels.slack.app_token
+- ref:channels.matrix.access_token
+- ref:channels.line.channel_secret
+- ref:channels.line.channel_access_token
+- ref:channels.onebot.access_token
+- ref:channels.wecom.token
+- ref:channels.wecom.encoding_aes_key
+- ref:channels.wecom_app.corp_secret
+- ref:channels.wecom_app.token
+- ref:channels.wecom_app.encoding_aes_key
+- ref:channels.wecom_aibot.token
+- ref:channels.wecom_aibot.encoding_aes_key
+- ref:channels.pico.token
+- ref:channels.irc.password
+- ref:channels.irc.nickserv_password
+- ref:channels.irc.sasl_password
+
+## Web Tool API Keys
+- ref:web.brave.api_key
+- ref:web.tavily.api_key
+- ref:web.perplexity.api_key
+- ref:web.glm_search.api_key
+
+**Note:**
+- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml
+- GLMSearch: Use `api_key` (single string) format in .security.yml
+
+## Skills Registry Tokens
+- ref:skills.github.token
+- ref:skills.clawhub.auth_token
+
+# Backward Compatibility
+
+You can still use direct values in config.json if needed:
+
+```json
+
+ {
+ "model_list": [
+ {
+ "model_name": "local-model",
+ "model": "ollama/llama3",
+ "api_base": "http://localhost:11434/v1",
+ "api_key": "ollama" // Direct value (no reference)
+ }
+ ]
+ }
+
+```
+
+You can also mix references and direct values:
+
+```json
+
+ {
+ "model_list": [
+ {
+ "model_name": "cloud-model",
+ "api_key": "ref:model_list.cloud-model.api_key" // From .security.yml
+ },
+ {
+ "model_name": "local-model",
+ "api_key": "ollama" // Direct value
+ }
+ ]
+ }
+
+```
+
+# Migration from Old Config
+
+## Step 1: Backup your config
+```bash
+cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
+```
+
+## Step 2: Copy the example security file
+```bash
+cp security.example.yml ~/.picoclaw/.security.yml
+```
+
+## Step 3: Fill in your API keys
+Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
+
+## Step 4: Update config.json references
+Replace sensitive values in ~/.picoclaw/config.json with ref: references.
+
+## Step 5: Test
+```bash
+picoclaw --version
+```
+
+If everything works, you can delete the backup:
+```bash
+rm ~/.picoclaw/config.json.backup
+```
+
+# Advanced Features
+
+## Multiple API Keys (Load Balancing & Failover)
+
+You can configure multiple API keys for both models and web tools to enable:
+- **Load balancing**: Requests are distributed across multiple keys
+- **Failover**: If a key fails, the system automatically switches to another key
+
+### Example: Model with Multiple Keys
+
+**.security.yml:**
+```yaml
+model_list:
+
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-key-1"
+ - "sk-proj-key-2"
+ - "sk-proj-key-3"
+
+```
+
+**config.json:**
+```json
+
+ {
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_key": "ref:model_list.gpt-5.4.api_key"
+ }
+ ]
+ }
+
+```
+
+### Example: Web Tool with Multiple Keys
+
+**.security.yml:**
+```yaml
+web:
+
+ brave:
+ api_keys:
+ - "BSA-key-1"
+ - "BSA-key-2"
+ tavily:
+ api_keys:
+ - "tvly-your-key" # Single key in array format
+ glm_search:
+ api_key: "your-glm-key" # GLMSearch uses single key format
+
+```
+
+**config.json:**
+```json
+
+ {
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "ref:web.brave.api_key"
+ }
+ }
+ }
+ }
+
+```
+
+### Single Key
+
+Use array format with one element:
+```yaml
+model_list:
+
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-your-key" # Single key in array format
+
+```
+
+### Multiple Keys (Load Balancing & Failover)
+
+Use array format with multiple elements:
+```yaml
+model_list:
+
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-key-1"
+ - "sk-proj-key-2"
+ - "sk-proj-key-3"
+
+```
+
+**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format.
+The single `api_key` (singular) format is NOT supported for models.
+
+### Model Index Matching
+
+The system supports intelligent model name matching in .security.yml:
+
+**Example 1: Exact Match**
+```yaml
+# config.json
+
+ {
+ "model_name": "gpt-5.4:0"
+ }
+
+# .security.yml (exact match with index)
+model_list:
+
+ gpt-5.4:0:
+ api_keys: ["key-1"]
+
+```
+
+**Example 2: Base Name Match**
+```yaml
+# config.json
+
+ {
+ "model_name": "gpt-5.4:0"
+ }
+
+# .security.yml (base name without index)
+model_list:
+
+ gpt-5.4:
+ api_keys: ["key-1"]
+
+```
+
+Both methods work. The base name match allows you to use simpler keys in .security.yml
+even when your config uses indexed model names for load balancing.
+
+### Security File Permissions
+
+The security file should have restricted permissions:
+
+```bash
+chmod 600 ~/.picoclaw/.security.yml
+```
+
+This ensures only the owner can read and write the file.
+
+# Security Best Practices
+
+1. Never commit .security.yml to version control
+2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
+3. Use different keys for different environments
+4. Rotate keys regularly and update .security.yml
+5. Encrypt backups containing .security.yml
+
+# Troubleshooting
+
+## Error: "model security entry not found"
+- Check that the model name in config.json matches exactly in .security.yml
+- Verify the model_list section exists in .security.yml
+
+## Error: "failed to load security config"
+- Ensure .security.yml exists in the same directory as config.json
+- Check YAML syntax is valid
+- Verify file permissions allow reading
+
+## Error: "unknown reference path"
+- Verify the reference format is correct
+- Check the path structure matches the examples above
+- Ensure all required sections exist in .security.yml
+*/
+package config
+
+// This file is documentation only
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 832d8bf17..fee800a76 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -6,10 +6,15 @@
package config
import (
+ "encoding/json"
"slices"
"strings"
)
+type migratable interface {
+ Migrate() (*Config, error)
+}
+
// buildModelWithProtocol constructs a model string with protocol prefix.
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added.
@@ -21,31 +26,31 @@ func buildModelWithProtocol(protocol, model string) string {
return protocol + "/" + model
}
-// providerMigrationConfig defines how to migrate a provider from old config to new format.
-type providerMigrationConfig struct {
- // providerNames are the possible names used in agents.defaults.provider
- providerNames []string
- // protocol is the protocol prefix for the model field
- protocol string
- // buildConfig creates the ModelConfig from ProviderConfig
- buildConfig func(p ProvidersConfig) (ModelConfig, bool)
-}
-
-// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
+// v0ConvertProvidersToModelList converts the old providersConfigV0 to a slice of ModelConfig.
// This enables backward compatibility with existing configurations.
// It preserves the user's configured model from agents.defaults.model when possible.
-func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
+func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
if cfg == nil {
return nil
}
+ // providerMigrationConfig defines how to migrate a provider from old config to new format.
+ type providerMigrationConfig struct {
+ // providerNames are the possible names used in agents.defaults.provider
+ providerNames []string
+ // protocol is the protocol prefix for the model field
+ protocol string
+ // buildConfig creates the ModelConfig from ProviderConfig
+ buildConfig func(p providersConfigV0) (modelConfigV0, bool)
+ }
+
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
- var result []ModelConfig
+ var result []modelConfigV0
// Track if we've applied the legacy model name fix (only for first provider)
legacyModelNameApplied := false
@@ -55,11 +60,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"openai", "gpt"},
protocol: "openai",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "openai",
Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey,
@@ -73,11 +78,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"anthropic", "claude"},
protocol: "anthropic",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "anthropic",
Model: "anthropic/claude-sonnet-4.6",
APIKey: p.Anthropic.APIKey,
@@ -91,11 +96,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"litellm"},
protocol: "litellm",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "litellm",
Model: "litellm/auto",
APIKey: p.LiteLLM.APIKey,
@@ -108,11 +113,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"openrouter"},
protocol: "openrouter",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "openrouter",
Model: "openrouter/auto",
APIKey: p.OpenRouter.APIKey,
@@ -125,11 +130,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"groq"},
protocol: "groq",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "groq",
Model: "groq/llama-3.1-70b-versatile",
APIKey: p.Groq.APIKey,
@@ -142,11 +147,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"zhipu", "glm"},
protocol: "zhipu",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "zhipu",
Model: "zhipu/glm-4",
APIKey: p.Zhipu.APIKey,
@@ -159,11 +164,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"vllm"},
protocol: "vllm",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "vllm",
Model: "vllm/auto",
APIKey: p.VLLM.APIKey,
@@ -176,11 +181,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"gemini", "google"},
protocol: "gemini",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "gemini",
Model: "gemini/gemini-pro",
APIKey: p.Gemini.APIKey,
@@ -193,11 +198,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"nvidia"},
protocol: "nvidia",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "nvidia",
Model: "nvidia/meta/llama-3.1-8b-instruct",
APIKey: p.Nvidia.APIKey,
@@ -210,11 +215,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"ollama"},
protocol: "ollama",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "ollama",
Model: "ollama/llama3",
APIKey: p.Ollama.APIKey,
@@ -227,11 +232,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"moonshot", "kimi"},
protocol: "moonshot",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "moonshot",
Model: "moonshot/kimi",
APIKey: p.Moonshot.APIKey,
@@ -244,11 +249,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"shengsuanyun"},
protocol: "shengsuanyun",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "shengsuanyun",
Model: "shengsuanyun/auto",
APIKey: p.ShengSuanYun.APIKey,
@@ -261,11 +266,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"deepseek"},
protocol: "deepseek",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "deepseek",
Model: "deepseek/deepseek-chat",
APIKey: p.DeepSeek.APIKey,
@@ -278,11 +283,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"cerebras"},
protocol: "cerebras",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "cerebras",
Model: "cerebras/llama-3.3-70b",
APIKey: p.Cerebras.APIKey,
@@ -295,11 +300,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"vivgrid"},
protocol: "vivgrid",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "vivgrid",
Model: "vivgrid/auto",
APIKey: p.Vivgrid.APIKey,
@@ -312,11 +317,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"volcengine", "doubao"},
protocol: "volcengine",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "volcengine",
Model: "volcengine/doubao-pro",
APIKey: p.VolcEngine.APIKey,
@@ -329,11 +334,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"github_copilot", "copilot"},
protocol: "github-copilot",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase,
@@ -344,11 +349,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"antigravity"},
protocol: "antigravity",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "antigravity",
Model: "antigravity/gemini-2.0-flash",
APIKey: p.Antigravity.APIKey,
@@ -359,11 +364,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"qwen", "tongyi"},
protocol: "qwen",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "qwen",
Model: "qwen/qwen-max",
APIKey: p.Qwen.APIKey,
@@ -376,11 +381,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"mistral"},
protocol: "mistral",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "mistral",
Model: "mistral/mistral-small-latest",
APIKey: p.Mistral.APIKey,
@@ -393,11 +398,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"avian"},
protocol: "avian",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "avian",
Model: "avian/deepseek/deepseek-v3.2",
APIKey: p.Avian.APIKey,
@@ -410,11 +415,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"longcat"},
protocol: "longcat",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
@@ -427,11 +432,11 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
{
providerNames: []string{"modelscope"},
protocol: "modelscope",
- buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ buildConfig: func(p providersConfigV0) (modelConfigV0, bool) {
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
- return ModelConfig{}, false
+ return modelConfigV0{}, false
}
- return ModelConfig{
+ return modelConfigV0{
ModelName: "modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIKey: p.ModelScope.APIKey,
@@ -469,83 +474,63 @@ 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
+// loadConfigV0 loads a legacy config (no version field)
+func loadConfigV0(data []byte) (migratable, error) {
+ var v0 configV0
+ if err := json.Unmarshal(data, &v0); err != nil {
+ return nil, err
}
- for i := range models {
- m := &models[i]
+ v0.migrateChannelConfigs()
- // 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
+ // Auto-migrate: if only legacy providers config exists, convert to model_list
+ if len(v0.ModelList) == 0 && !v0.Providers.IsEmpty() {
+ newModelList := v0ConvertProvidersToModelList(&v0)
+ // Convert []ModelConfig to []modelConfigV0
+ v0.ModelList = make([]modelConfigV0, len(newModelList))
+ for i, m := range newModelList {
+ v0.ModelList[i] = modelConfigV0{
+ ModelName: m.ModelName,
+ Model: m.Model,
+ APIBase: m.APIBase,
+ Proxy: m.Proxy,
+ Fallbacks: m.Fallbacks,
+ AuthMethod: m.AuthMethod,
+ ConnectMode: m.ConnectMode,
+ Workspace: m.Workspace,
+ RPM: m.RPM,
+ MaxTokensField: m.MaxTokensField,
+ RequestTimeout: m.RequestTimeout,
+ ThinkingLevel: m.ThinkingLevel,
+ APIKey: m.APIKey,
+ APIKeys: m.APIKeys,
+ }
}
}
+
+ return &v0, nil
+}
+
+// loadConfigV1 loads a version 1 config (current schema)
+func loadConfig(data []byte) (*Config, error) {
+ cfg := DefaultConfig()
+
+ // Pre-scan the JSON to check how many model_list entries the user provided.
+ // Go's JSON decoder reuses existing slice backing-array elements rather than
+ // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
+ // would silently inherit values from the DefaultConfig template at the same
+ // index position. We only reset cfg.ModelList when the user actually provides
+ // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
+ var tmp Config
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return nil, err
+ }
+ if len(tmp.ModelList) > 0 {
+ cfg.ModelList = nil
+ }
+
+ if err := json.Unmarshal(data, cfg); err != nil {
+ return nil, err
+ }
+ return cfg, nil
}
diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go
new file mode 100644
index 000000000..c884a6b5d
--- /dev/null
+++ b/pkg/config/migration_integration_test.go
@@ -0,0 +1,568 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestMigration_Integration_LegacyConfigWithoutWorkspace tests the issue reported:
+// User configured Model and Provider but no Workspace - settings should not be lost
+func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
+ // Create a temporary directory for test config files
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ // Create a legacy config (version 0) with Model and Provider but NO Workspace
+ // This simulates the real-world scenario where user settings would be lost
+ legacyConfig := `{
+ "agents": {
+ "defaults": {
+ "provider": "openai",
+ "model": "gpt-4o",
+ "max_tokens": 8192,
+ "temperature": 0.7
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "test-token"
+ }
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {
+ "enabled": true
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ // Load the config - this should trigger migration
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // Verify version is updated
+ if cfg.Version != CurrentVersion {
+ t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
+ }
+
+ // CRITICAL: Verify that user's settings are preserved
+ // This was the bug - these settings were lost when Workspace was empty
+ if cfg.Agents.Defaults.Provider != "openai" {
+ t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai")
+ }
+ // Old "model" field is migrated to "model_name" field
+ if cfg.Agents.Defaults.ModelName != "gpt-4o" {
+ t.Errorf(
+ "ModelName = %q, want %q (user's setting should be preserved)",
+ cfg.Agents.Defaults.ModelName, "gpt-4o",
+ )
+ }
+ // GetModelName() should also return the migrated value
+ if cfg.Agents.Defaults.GetModelName() != "gpt-4o" {
+ t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "gpt-4o")
+ }
+ if cfg.Agents.Defaults.MaxTokens != 8192 {
+ t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 8192)
+ }
+ if cfg.Agents.Defaults.Temperature == nil {
+ t.Error("Temperature should not be nil")
+ } else if *cfg.Agents.Defaults.Temperature != 0.7 {
+ t.Errorf("Temperature = %v, want %v", *cfg.Agents.Defaults.Temperature, 0.7)
+ }
+
+ // Verify Workspace has a default value (should not be empty)
+ if cfg.Agents.Defaults.Workspace == "" {
+ t.Error("Workspace should have a default value, not be empty")
+ }
+
+ // Verify other config sections are preserved
+ if !cfg.Channels.Telegram.Enabled {
+ t.Error("Telegram.Enabled should be true")
+ }
+ if cfg.Channels.Telegram.Token() != "test-token" {
+ t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token")
+ }
+ if cfg.Gateway.Port != 18790 {
+ t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
+ }
+}
+
+// TestMigration_Integration_LegacyConfigWithWorkspace tests migration with Workspace set
+func TestMigration_Integration_LegacyConfigWithWorkspace(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ legacyConfig := `{
+ "agents": {
+ "defaults": {
+ "workspace": "/custom/workspace",
+ "provider": "deepseek",
+ "model": "deepseek-chat",
+ "max_tokens": 16384
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": false
+ }
+ },
+ "gateway": {
+ "host": "0.0.0.0",
+ "port": 8080
+ },
+ "tools": {
+ "web": {
+ "enabled": false
+ }
+ },
+ "heartbeat": {
+ "enabled": false
+ },
+ "devices": {
+ "enabled": true
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // All user settings should be preserved
+ if cfg.Agents.Defaults.Workspace != "/custom/workspace" {
+ t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "/custom/workspace")
+ }
+ if cfg.Agents.Defaults.Provider != "deepseek" {
+ t.Errorf("Provider = %q, want %q", cfg.Agents.Defaults.Provider, "deepseek")
+ }
+ if cfg.Agents.Defaults.ModelName != "deepseek-chat" {
+ t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-chat")
+ }
+ if cfg.Agents.Defaults.MaxTokens != 16384 {
+ t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 16384)
+ }
+
+ // Verify other settings
+ if cfg.Gateway.Port != 8080 {
+ t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 8080)
+ }
+ if !cfg.Devices.Enabled {
+ t.Error("Devices.Enabled should be true")
+ }
+}
+
+// TestMigration_Integration_PreservesAllAgentsFields tests that ALL Agents fields are preserved
+func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ legacyConfig := `{
+ "agents": {
+ "defaults": {
+ "workspace": "",
+ "restrict_to_workspace": false,
+ "allow_read_outside_workspace": true,
+ "provider": "anthropic",
+ "model": "claude-opus-4",
+ "model_fallbacks": ["claude-sonnet-4", "claude-haiku-4"],
+ "image_model": "claude-opus-4-vision",
+ "image_model_fallbacks": ["claude-sonnet-4-vision"],
+ "max_tokens": 4096,
+ "temperature": 0.5,
+ "max_tool_iterations": 100,
+ "summarize_message_threshold": 30,
+ "summarize_token_percent": 80,
+ "max_media_size": 10485760
+ },
+ "list": [
+ {
+ "id": "special-agent",
+ "default": false,
+ "name": "Special Agent",
+ "workspace": "/special/workspace"
+ }
+ ]
+ },
+ "channels": {
+ "telegram": {"enabled": false}
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {"enabled": true}
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // Verify ALL defaults fields are preserved
+ d := cfg.Agents.Defaults
+
+ if d.RestrictToWorkspace != false {
+ t.Errorf("RestrictToWorkspace = %v, want false", d.RestrictToWorkspace)
+ }
+ if d.AllowReadOutsideWorkspace != true {
+ t.Errorf("AllowReadOutsideWorkspace = %v, want true", d.AllowReadOutsideWorkspace)
+ }
+ if d.Provider != "anthropic" {
+ t.Errorf("Provider = %q, want %q", d.Provider, "anthropic")
+ }
+ if d.ModelName != "claude-opus-4" {
+ t.Errorf("ModelName = %q, want %q", d.ModelName, "claude-opus-4")
+ }
+ if len(d.ModelFallbacks) != 2 {
+ t.Errorf("len(ModelFallbacks) = %d, want 2", len(d.ModelFallbacks))
+ } else {
+ if d.ModelFallbacks[0] != "claude-sonnet-4" {
+ t.Errorf("ModelFallbacks[0] = %q, want %q", d.ModelFallbacks[0], "claude-sonnet-4")
+ }
+ if d.ModelFallbacks[1] != "claude-haiku-4" {
+ t.Errorf("ModelFallbacks[1] = %q, want %q", d.ModelFallbacks[1], "claude-haiku-4")
+ }
+ }
+ if d.ImageModel != "claude-opus-4-vision" {
+ t.Errorf("ImageModel = %q, want %q", d.ImageModel, "claude-opus-4-vision")
+ }
+ if len(d.ImageModelFallbacks) != 1 {
+ t.Errorf("len(ImageModelFallbacks) = %d, want 1", len(d.ImageModelFallbacks))
+ } else if d.ImageModelFallbacks[0] != "claude-sonnet-4-vision" {
+ t.Errorf("ImageModelFallbacks[0] = %q, want %q", d.ImageModelFallbacks[0], "claude-sonnet-4-vision")
+ }
+ if d.MaxTokens != 4096 {
+ t.Errorf("MaxTokens = %d, want %d", d.MaxTokens, 4096)
+ }
+ if d.Temperature == nil || *d.Temperature != 0.5 {
+ t.Errorf("Temperature = %v, want 0.5", d.Temperature)
+ }
+ if d.MaxToolIterations != 100 {
+ t.Errorf("MaxToolIterations = %d, want %d", d.MaxToolIterations, 100)
+ }
+ if d.SummarizeMessageThreshold != 30 {
+ t.Errorf("SummarizeMessageThreshold = %d, want %d", d.SummarizeMessageThreshold, 30)
+ }
+ if d.SummarizeTokenPercent != 80 {
+ t.Errorf("SummarizeTokenPercent = %d, want %d", d.SummarizeTokenPercent, 80)
+ }
+ if d.MaxMediaSize != 10485760 {
+ t.Errorf("MaxMediaSize = %d, want %d", d.MaxMediaSize, 10485760)
+ }
+
+ // Verify agent list is preserved
+ if len(cfg.Agents.List) != 1 {
+ t.Fatalf("len(Agents.List) = %d, want 1", len(cfg.Agents.List))
+ }
+ if cfg.Agents.List[0].ID != "special-agent" {
+ t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent")
+ }
+ if cfg.Agents.List[0].Workspace != "/special/workspace" {
+ t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace")
+ }
+
+ // Workspace should have default since it was empty in legacy config
+ if d.Workspace == "" {
+ t.Error("Workspace should have a default value, not be empty")
+ }
+}
+
+// TestMigration_Integration_ChannelsConfigMigrated tests channel config migration
+func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ // Legacy config with old channel field formats
+ legacyConfig := `{
+ "agents": {
+ "defaults": {}
+ },
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "discord-token",
+ "mention_only": true
+ },
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://127.0.0.1:3001",
+ "group_trigger_prefix": ["/", "!"]
+ }
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {"enabled": true}
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // Discord: mention_only should be migrated to group_trigger.mention_only
+ if cfg.Channels.Discord.GroupTrigger.MentionOnly != true {
+ t.Error("Discord.GroupTrigger.MentionOnly should be true after migration")
+ }
+
+ // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes
+ if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 {
+ t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes))
+ } else {
+ if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
+ t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/")
+ }
+ if cfg.Channels.OneBot.GroupTrigger.Prefixes[1] != "!" {
+ t.Errorf("Prefixes[1] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[1], "!")
+ }
+ }
+}
+
+// TestMigration_Integration_RoundTrip_SerializeAndLoad tests that migrated config can be saved and reloaded
+func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ legacyConfig := `{
+ "agents": {
+ "defaults": {
+ "provider": "openai",
+ "model": "gpt-4o",
+ "max_tokens": 8192
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "test-token"
+ }
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {"enabled": true}
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ // First load - triggers migration and saves
+ cfg1, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("First LoadConfig failed: %v", err)
+ }
+
+ // Read the migrated config from disk
+ migratedData, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("Failed to read migrated config: %v", err)
+ }
+
+ // Verify it has the current version
+ var versionCheck struct {
+ Version int `json:"version"`
+ }
+ if err = json.Unmarshal(migratedData, &versionCheck); err != nil {
+ t.Fatalf("Failed to parse migrated config version: %v", err)
+ }
+ if versionCheck.Version != CurrentVersion {
+ t.Errorf("Migrated config version = %d, want %d", versionCheck.Version, CurrentVersion)
+ }
+
+ // Second load - should load the migrated config without changes
+ cfg2, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("Second LoadConfig failed: %v", err)
+ }
+
+ // Verify configs are identical
+ if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider {
+ t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider)
+ }
+ if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName {
+ t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName)
+ }
+ if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens {
+ t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens)
+ }
+}
+
+// TestMigration_Integration_EmptyAgentsDefaults tests migration with completely empty agents config
+func TestMigration_Integration_EmptyAgentsDefaults(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ // Legacy config with empty agents defaults
+ legacyConfig := `{
+ "agents": {
+ "defaults": {}
+ },
+ "channels": {
+ "telegram": {"enabled": false}
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {"enabled": true}
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // Workspace should have default value
+ if cfg.Agents.Defaults.Workspace == "" {
+ t.Error("Workspace should have a default value")
+ }
+
+ // Note: When fields are explicitly set in config (even to zero values),
+ // they override defaults. This is correct JSON unmarshaling behavior.
+ // Users should set values they want; defaults are for unspecified fields.
+ if cfg.Agents.Defaults.MaxTokens == 0 {
+ // This is expected when users don't set max_tokens in their config
+ // The zero value (0) from the legacy config is preserved
+ }
+ if cfg.Agents.Defaults.MaxToolIterations == 0 {
+ // Same as above - zero value is preserved if it was in the config
+ }
+}
+
+// TestMigration_Integration_ModelNameField tests migration using new model_name field
+func TestMigration_Integration_ModelNameField(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ // Legacy config using the new model_name field
+ legacyConfig := `{
+ "agents": {
+ "defaults": {
+ "provider": "deepseek",
+ "model_name": "deepseek-reasoner",
+ "model_fallbacks": ["deepseek-chat"]
+ }
+ },
+ "channels": {
+ "telegram": {"enabled": false}
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ },
+ "tools": {
+ "web": {"enabled": true}
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ },
+ "devices": {
+ "enabled": false
+ }
+ }`
+
+ if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
+ t.Fatalf("Failed to write legacy config: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // model_name field should be preserved
+ if cfg.Agents.Defaults.ModelName != "deepseek-reasoner" {
+ t.Errorf("ModelName = %q, want %q", cfg.Agents.Defaults.ModelName, "deepseek-reasoner")
+ }
+
+ // GetModelName() should return model_name, not model (deprecated)
+ if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" {
+ t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner")
+ }
+
+ if len(cfg.Agents.Defaults.ModelFallbacks) != 1 {
+ t.Errorf("len(ModelFallbacks) = %d, want 1", len(cfg.Agents.Defaults.ModelFallbacks))
+ } else if cfg.Agents.Defaults.ModelFallbacks[0] != "deepseek-chat" {
+ t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
+ }
+}
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index bea5b9034..aeabe9730 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -11,10 +11,10 @@ import (
)
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{
- ProviderConfig: ProviderConfig{
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{
+ providerConfigV0: providerConfigV0{
APIKey: "sk-test-key",
APIBase: "https://custom.api.com/v1",
},
@@ -22,7 +22,7 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -40,16 +40,15 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
}
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- Anthropic: ProviderConfig{
- APIKey: "ant-key",
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ Anthropic: providerConfigV0{
APIBase: "https://custom.anthropic.com",
},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -64,16 +63,15 @@ func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
}
func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- LiteLLM: ProviderConfig{
- APIKey: "litellm-key",
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ LiteLLM: providerConfigV0{
APIBase: "http://localhost:4000/v1",
},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -91,15 +89,15 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
}
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
- Groq: ProviderConfig{APIKey: "groq-key"},
- Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
+ Groq: providerConfigV0{APIKey: "groq-key"},
+ Zhipu: providerConfigV0{APIKey: "zhipu-key"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 3 {
t.Fatalf("len(result) = %d, want 3", len(result))
@@ -119,11 +117,11 @@ func TestConvertProvidersToModelList_Multiple(t *testing.T) {
}
func TestConvertProvidersToModelList_Empty(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{},
+ cfg := &configV0{
+ Providers: providersConfigV0{},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 0 {
t.Errorf("len(result) = %d, want 0", len(result))
@@ -131,7 +129,7 @@ func TestConvertProvidersToModelList_Empty(t *testing.T) {
}
func TestConvertProvidersToModelList_Nil(t *testing.T) {
- result := ConvertProvidersToModelList(nil)
+ result := v0ConvertProvidersToModelList(nil)
if result != nil {
t.Errorf("result = %v, want nil", result)
@@ -139,35 +137,38 @@ func TestConvertProvidersToModelList_Nil(t *testing.T) {
}
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
- LiteLLM: ProviderConfig{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
- Anthropic: ProviderConfig{APIKey: "key2"},
- OpenRouter: ProviderConfig{APIKey: "key3"},
- Groq: ProviderConfig{APIKey: "key4"},
- Zhipu: ProviderConfig{APIKey: "key5"},
- VLLM: ProviderConfig{APIKey: "key6"},
- Gemini: ProviderConfig{APIKey: "key7"},
- Nvidia: ProviderConfig{APIKey: "key8"},
- Ollama: ProviderConfig{APIKey: "key9"},
- Moonshot: ProviderConfig{APIKey: "key10"},
- ShengSuanYun: ProviderConfig{APIKey: "key11"},
- DeepSeek: ProviderConfig{APIKey: "key12"},
- Cerebras: ProviderConfig{APIKey: "key13"},
- Vivgrid: ProviderConfig{APIKey: "key14"},
- VolcEngine: ProviderConfig{APIKey: "key15"},
- GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
- Antigravity: ProviderConfig{AuthMethod: "oauth"},
- Qwen: ProviderConfig{APIKey: "key17"},
- Mistral: ProviderConfig{APIKey: "key18"},
- Avian: ProviderConfig{APIKey: "key19"},
- LongCat: ProviderConfig{APIKey: "key-longcat"},
- ModelScope: ProviderConfig{APIKey: "key-modelscope"},
+ // This test verifies that when providers have at least one configured field,
+ // they are converted. GitHubCopilot has ConnectMode set, Antigravity has AuthMethod.
+ // Other providers have no configuration, so they won't be converted.
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}},
+ LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"},
+ Anthropic: providerConfigV0{APIKey: "key2"},
+ OpenRouter: providerConfigV0{APIKey: "key3"},
+ Groq: providerConfigV0{APIKey: "key4"},
+ Zhipu: providerConfigV0{APIKey: "key5"},
+ VLLM: providerConfigV0{APIKey: "key6"},
+ Gemini: providerConfigV0{APIKey: "key7"},
+ Nvidia: providerConfigV0{APIKey: "key8"},
+ Ollama: providerConfigV0{APIKey: "key9"},
+ Moonshot: providerConfigV0{APIKey: "key10"},
+ ShengSuanYun: providerConfigV0{APIKey: "key11"},
+ DeepSeek: providerConfigV0{APIKey: "key12"},
+ Cerebras: providerConfigV0{APIKey: "key13"},
+ Vivgrid: providerConfigV0{APIKey: "key14"},
+ VolcEngine: providerConfigV0{APIKey: "key15"},
+ GitHubCopilot: providerConfigV0{ConnectMode: "grpc"},
+ Antigravity: providerConfigV0{AuthMethod: "oauth"},
+ Qwen: providerConfigV0{APIKey: "key17"},
+ Mistral: providerConfigV0{APIKey: "key18"},
+ Avian: providerConfigV0{APIKey: "key19"},
+ LongCat: providerConfigV0{APIKey: "key-longcat"},
+ ModelScope: providerConfigV0{APIKey: "key-modelscope"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
// All 23 providers should be converted
if len(result) != 23 {
@@ -176,10 +177,10 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
}
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{
- ProviderConfig: ProviderConfig{
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{
+ providerConfigV0: providerConfigV0{
APIKey: "key",
Proxy: "http://proxy:8080",
},
@@ -187,7 +188,7 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -199,16 +200,16 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
}
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- Ollama: ProviderConfig{
- APIKey: "ollama-key",
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ Ollama: providerConfigV0{
+ APIBase: "http://localhost:11434",
RequestTimeout: 300,
},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -220,17 +221,17 @@ func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
}
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
- cfg := &Config{
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{
- ProviderConfig: ProviderConfig{
+ cfg := &configV0{
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{
+ providerConfigV0: providerConfigV0{
AuthMethod: "oauth",
},
},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 0 {
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
@@ -240,19 +241,19 @@ func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
// Tests for preserving user's configured model during migration
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "deepseek",
Model: "deepseek-reasoner",
},
},
- Providers: ProvidersConfig{
- DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ Providers: providersConfigV0{
+ DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -265,19 +266,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
}
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "openai",
Model: "gpt-4-turbo",
},
},
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -289,19 +290,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
}
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "claude", // alternative name
Model: "claude-opus-4-20250514",
},
},
- Providers: ProvidersConfig{
- Anthropic: ProviderConfig{APIKey: "sk-ant"},
+ Providers: providersConfigV0{
+ Anthropic: providerConfigV0{APIKey: "sk-ant"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -313,19 +314,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T)
}
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "qwen",
Model: "qwen-plus",
},
},
- Providers: ProvidersConfig{
- Qwen: ProviderConfig{APIKey: "sk-qwen"},
+ Providers: providersConfigV0{
+ Qwen: providerConfigV0{APIKey: "sk-qwen"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -337,19 +338,19 @@ func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
}
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "deepseek",
Model: "", // no model specified
},
},
- Providers: ProvidersConfig{
- DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ Providers: providersConfigV0{
+ DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -362,20 +363,20 @@ func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
}
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "deepseek",
Model: "deepseek-reasoner",
},
},
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
- DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}},
+ DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
@@ -400,20 +401,20 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
tests := []struct {
providerAlias string
expectedModel string
- provider ProviderConfig
+ provider providerConfigV0
}{
- {"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
- {"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
- {"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
- {"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
- {"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
+ {"gpt", "openai/gpt-4-custom", providerConfigV0{APIKey: "key"}},
+ {"claude", "anthropic/claude-custom", providerConfigV0{APIKey: "key"}},
+ {"doubao", "volcengine/doubao-custom", providerConfigV0{APIKey: "key"}},
+ {"tongyi", "qwen/qwen-custom", providerConfigV0{APIKey: "key"}},
+ {"kimi", "moonshot/kimi-custom", providerConfigV0{APIKey: "key"}},
}
for _, tt := range tests {
t.Run(tt.providerAlias, func(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: tt.providerAlias,
Model: strings.TrimPrefix(
tt.expectedModel,
@@ -421,13 +422,13 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
),
},
},
- Providers: ProvidersConfig{},
+ Providers: providersConfigV0{},
}
// Set the appropriate provider config
switch tt.providerAlias {
case "gpt":
- cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
+ cfg.Providers.OpenAI = openAIProviderConfigV0{providerConfigV0: tt.provider}
case "claude":
cfg.Providers.Anthropic = tt.provider
case "doubao":
@@ -444,7 +445,7 @@ func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
)
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
@@ -466,19 +467,21 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T
// - No provider field set
// - model = "glm-4.7"
// - Only zhipu has API key configured
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "", // Not set
Model: "glm-4.7",
},
},
- Providers: ProvidersConfig{
- Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
+ Providers: providersConfigV0{
+ Zhipu: providerConfigV0{
+ APIKey: "test-zhipu-key",
+ },
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -499,20 +502,20 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
// When multiple providers are configured but no provider field is set,
// the FIRST provider (in migration order) will use userModel as ModelName
// for backward compatibility with legacy implicit provider selection
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "", // Not set
Model: "some-model",
},
},
- Providers: ProvidersConfig{
- OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
- Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ Providers: providersConfigV0{
+ OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}},
+ Zhipu: providerConfigV0{APIKey: "zhipu-key"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
@@ -532,19 +535,19 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Edge case: no provider, no model
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "",
Model: "",
},
},
- Providers: ProvidersConfig{
- Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ Providers: providersConfigV0{
+ Zhipu: providerConfigV0{APIKey: "zhipu-key"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
@@ -585,19 +588,19 @@ func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
// Test for legacy config with protocol prefix in model name
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
- cfg := &Config{
- Agents: AgentsConfig{
- Defaults: AgentDefaults{
+ cfg := &configV0{
+ Agents: agentsConfigV0{
+ Defaults: agentDefaultsV0{
Provider: "", // No explicit provider
Model: "openrouter/auto", // Model already has protocol prefix
},
},
- Providers: ProvidersConfig{
- OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
+ Providers: providersConfigV0{
+ OpenRouter: providerConfigV0{APIKey: "sk-or-test"},
},
}
- result := ConvertProvidersToModelList(cfg)
+ result := v0ConvertProvidersToModelList(cfg)
if len(result) < 1 {
t.Fatalf("len(result) = %d, want at least 1", len(result))
@@ -613,143 +616,3 @@ 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)
- }
-}
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
index 9bc600ed9..3252d2f26 100644
--- a/pkg/config/model_config_test.go
+++ b/pkg/config/model_config_test.go
@@ -13,12 +13,20 @@ import (
)
func TestGetModelConfig_Found(t *testing.T) {
- cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
- {ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
+ cfg := (&Config{
+ Version: CurrentVersion,
+ ModelList: []*ModelConfig{
+ {ModelName: "test-model", Model: "openai/gpt-4o"},
+ {ModelName: "other-model", Model: "anthropic/claude"},
},
- }
+ }).WithSecurity(&SecurityConfig{ModelList: map[string]ModelSecurityEntry{
+ "test-model:0": {
+ APIKeys: []string{"key1"},
+ },
+ "other-model:0": {
+ APIKeys: []string{"key2"},
+ },
+ }})
result, err := cfg.GetModelConfig("test-model")
if err != nil {
@@ -30,11 +38,17 @@ func TestGetModelConfig_Found(t *testing.T) {
}
func TestGetModelConfig_NotFound(t *testing.T) {
- cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
+ cfg := (&Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "test-model", Model: "openai/gpt-4o"},
},
- }
+ }).WithSecurity(&SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "test-model:0": {
+ APIKeys: []string{"key1"},
+ },
+ },
+ })
_, err := cfg.GetModelConfig("nonexistent")
if err == nil {
@@ -44,7 +58,7 @@ func TestGetModelConfig_NotFound(t *testing.T) {
func TestGetModelConfig_EmptyList(t *testing.T) {
cfg := &Config{
- ModelList: []ModelConfig{},
+ ModelList: []*ModelConfig{},
}
_, err := cfg.GetModelConfig("any-model")
@@ -54,13 +68,25 @@ func TestGetModelConfig_EmptyList(t *testing.T) {
}
func TestGetModelConfig_RoundRobin(t *testing.T) {
- cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
- {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
- {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
+ cfg := (&Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "lb-model", Model: "openai/gpt-4o-1"},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-2"},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-3"},
},
- }
+ }).WithSecurity(&SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "lb-model:0": {
+ APIKeys: []string{"key1"},
+ },
+ "lb-model:1": {
+ APIKeys: []string{"key2"},
+ },
+ "lb-model:2": {
+ APIKeys: []string{"key3"},
+ },
+ },
+ })
// Test round-robin distribution
results := make(map[string]int)
@@ -84,10 +110,10 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
rrCounter.Store(0)
cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
- {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
- {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
+ ModelList: []*ModelConfig{
+ {ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}},
},
}
@@ -112,9 +138,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
func TestGetModelConfig_Concurrent(t *testing.T) {
cfg := &Config{
- ModelList: []ModelConfig{
- {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
- {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
+ ModelList: []*ModelConfig{
+ {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
+ {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
},
}
@@ -143,39 +169,7 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
-func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
- tests := []struct {
- name string
- defaults AgentDefaults
- wantName string
- }{
- {
- name: "new model_name field only",
- defaults: AgentDefaults{ModelName: "new-model"},
- wantName: "new-model",
- },
- {
- name: "old model field only",
- defaults: AgentDefaults{Model: "legacy-model"},
- wantName: "legacy-model",
- },
- {
- name: "both fields - model_name takes precedence",
- defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
- wantName: "new-model",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := tt.defaults.GetModelName(); got != tt.wantName {
- t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
- }
- })
- }
-}
-
-func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
+func TestAgentDefaultsV0_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
json string
@@ -200,7 +194,7 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- var defaults AgentDefaults
+ var defaults agentDefaultsV0
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
@@ -211,69 +205,6 @@ func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
}
}
-func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
- // Test complete config with both old and new formats
- oldFormat := `{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
- "max_tokens": 4096
- }
- },
- "model_list": [
- {
- "model_name": "gpt4",
- "model": "openai/gpt-4o",
- "api_key": "test-key"
- }
- ]
- }`
-
- newFormat := `{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "model_name": "gpt4",
- "max_tokens": 4096
- }
- },
- "model_list": [
- {
- "model_name": "gpt4",
- "model": "openai/gpt-4o",
- "api_key": "test-key"
- }
- ]
- }`
-
- for name, jsonStr := range map[string]string{
- "old format (model)": oldFormat,
- "new format (model_name)": newFormat,
- } {
- t.Run(name, func(t *testing.T) {
- cfg := &Config{}
- if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
- t.Fatalf("Unmarshal error: %v", err)
- }
-
- // Check that GetModelName returns correct value
- if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
- t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
- }
-
- // Check that GetModelConfig works
- modelCfg, err := cfg.GetModelConfig("gpt4")
- if err != nil {
- t.Fatalf("GetModelConfig error: %v", err)
- }
- if modelCfg.Model != "openai/gpt-4o" {
- t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
- }
- })
- }
-}
-
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string
@@ -329,7 +260,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
{
name: "valid list",
config: &Config{
- ModelList: []ModelConfig{
+ ModelList: []*ModelConfig{
{ModelName: "test1", Model: "openai/gpt-4o"},
{ModelName: "test2", Model: "anthropic/claude"},
},
@@ -339,7 +270,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
{
name: "invalid entry",
config: &Config{
- ModelList: []ModelConfig{
+ ModelList: []*ModelConfig{
{ModelName: "test1", Model: "openai/gpt-4o"},
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
},
@@ -350,7 +281,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
{
name: "empty list",
config: &Config{
- ModelList: []ModelConfig{},
+ ModelList: []*ModelConfig{},
},
wantErr: false,
},
@@ -358,10 +289,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
// Load balancing: multiple entries with same model_name are allowed
name: "duplicate model_name for load balancing",
config: &Config{
- ModelList: []ModelConfig{
- {ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
- {ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
- },
+ ModelList: []*ModelConfig{},
},
wantErr: false, // Changed: duplicates are allowed for load balancing
},
@@ -369,7 +297,7 @@ func TestConfig_ValidateModelList(t *testing.T) {
// Load balancing: non-adjacent entries with same model_name are also allowed
name: "duplicate model_name non-adjacent for load balancing",
config: &Config{
- ModelList: []ModelConfig{
+ ModelList: []*ModelConfig{
{ModelName: "model-a", Model: "openai/gpt-4o"},
{ModelName: "model-b", Model: "anthropic/claude"},
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go
index b899b991c..cc529905c 100644
--- a/pkg/config/multikey_test.go
+++ b/pkg/config/multikey_test.go
@@ -5,15 +5,15 @@ import (
)
func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
- models := []ModelConfig{
+ models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
- APIKey: "single-key",
+ apiKeys: []string{"single-key"},
},
}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
if len(result) != 1 {
t.Fatalf("expected 1 model, got %d", len(result))
@@ -23,8 +23,8 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
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 result[0].APIKey() != "single-key" {
+ t.Errorf("expected api_key 'single-key', got %q", result[0].APIKey())
}
if len(result[0].Fallbacks) != 0 {
@@ -33,16 +33,16 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
}
func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
- models := []ModelConfig{
+ models := []*ModelConfig{
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://api.example.com",
- APIKeys: []string{"key1", "key2", "key3"},
+ apiKeys: []string{"key1", "key2", "key3"},
},
}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
// Should expand to 3 models
if len(result) != 3 {
@@ -54,8 +54,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
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 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))
@@ -72,8 +72,8 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
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)
+ if second.APIKey() != "key2" {
+ t.Errorf("expected second api_key 'key2', got %q", second.APIKey())
}
// Third entry should be key3
@@ -81,22 +81,21 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
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)
+ if third.APIKey() != "key3" {
+ t.Errorf("expected third api_key 'key3', got %q", third.APIKey())
}
}
func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
- models := []ModelConfig{
+ models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
- APIKey: "key0",
- APIKeys: []string{"key1", "key2"},
+ apiKeys: []string{"key0", "key1", "key2"},
},
}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
// Should expand to 3 models (key0 from APIKey + key1, key2 from APIKeys)
if len(result) != 3 {
@@ -105,8 +104,8 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
// Primary should use key0
primary := result[2]
- if primary.APIKey != "key0" {
- t.Errorf("expected primary api_key 'key0', got %q", primary.APIKey)
+ 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))
@@ -114,16 +113,15 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
}
func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
- models := []ModelConfig{
- {
- ModelName: "gpt-4",
- Model: "openai/gpt-4o",
- APIKeys: []string{"key1", "key2"},
- Fallbacks: []string{"claude-3"},
- },
+ modelCfg := &ModelConfig{
+ ModelName: "gpt-4",
+ Model: "openai/gpt-4o",
}
+ modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
+ modelCfg.Fallbacks = []string{"claude-3"}
+ models := []*ModelConfig{modelCfg}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
primary := result[1]
// With 2 keys, we get 1 key fallback + 1 existing fallback = 2 total
@@ -141,16 +139,15 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
}
func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
- models := []ModelConfig{
+ models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
- APIKey: "",
- APIKeys: []string{},
+ apiKeys: []string{},
},
}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
// Should keep as-is with no changes
if len(result) != 1 {
@@ -163,25 +160,25 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
}
func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
- models := []ModelConfig{
+ models := []*ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
- APIKey: "key1",
- APIKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
+ apiKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
},
}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
+ t.Logf("result: %#v", result)
// 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 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))
@@ -189,21 +186,20 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
}
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",
- },
+ modelCfg := &ModelConfig{
+ ModelName: "gpt-4",
+ Model: "openai/gpt-4o",
+ APIBase: "https://api.example.com",
+ Proxy: "http://proxy:8080",
+ RPM: 60,
+ MaxTokensField: "max_completion_tokens",
+ RequestTimeout: 30,
+ ThinkingLevel: "high",
}
+ modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
+ models := []*ModelConfig{modelCfg}
- result := ExpandMultiKeyModels(models)
+ result := expandMultiKeyModels(models)
// Check primary entry preserves all fields
primary := result[1]
@@ -250,13 +246,13 @@ func TestMergeAPIKeys(t *testing.T) {
expected: nil,
},
{
- name: "only apiKey",
+ name: "only ApiKey",
apiKey: "key1",
apiKeys: nil,
expected: []string{"key1"},
},
{
- name: "only apiKeys",
+ name: "only ApiKeys",
apiKey: "",
apiKeys: []string{"key1", "key2"},
expected: []string{"key1", "key2"},
diff --git a/pkg/config/security.go b/pkg/config/security.go
new file mode 100644
index 000000000..fe2111280
--- /dev/null
+++ b/pkg/config/security.go
@@ -0,0 +1,220 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/caarlos0/env/v11"
+ "github.com/tencent-connect/botgo/log"
+ "gopkg.in/yaml.v3"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+)
+
+const (
+ SecurityConfigFile = ".security.yml"
+)
+
+// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
+// This data is loaded from security.yml and kept separate from the main config
+type SecurityConfig struct {
+ // Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
+ // for load balancing with same model_name. The suffix ":N" is used to distinguish
+ // multiple configs that share the same base model_name.
+ ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"`
+
+ // Channel tokens/secrets
+ Channels ChannelsSecurity `yaml:"channels,omitempty"`
+
+ Web WebToolsSecurity `yaml:"web,omitempty"`
+ Skills SkillsSecurity `yaml:"skills,omitempty"`
+}
+
+// ModelSecurityEntry stores security data for a model
+type ModelSecurityEntry struct {
+ APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
+}
+
+// ChannelsSecurity stores channel-related security data
+type ChannelsSecurity struct {
+ Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
+ Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
+ Discord *DiscordSecurity `yaml:"discord,omitempty"`
+ Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
+ QQ *QQSecurity `yaml:"qq,omitempty"`
+ DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
+ Slack *SlackSecurity `yaml:"slack,omitempty"`
+ Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
+ LINE *LINESecurity `yaml:"line,omitempty"`
+ OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
+ WeCom *WeComSecurity `yaml:"wecom,omitempty"`
+ WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"`
+ WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"`
+ Pico *PicoSecurity `yaml:"pico,omitempty"`
+ IRC *IRCSecurity `yaml:"irc,omitempty"`
+}
+
+type TelegramSecurity struct {
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+}
+
+type FeishuSecurity struct {
+ AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
+}
+
+type DiscordSecurity struct {
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+}
+
+type WeixinSecurity struct {
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
+}
+
+type QQSecurity struct {
+ AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
+}
+
+type DingTalkSecurity struct {
+ ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
+}
+
+type SlackSecurity struct {
+ BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+}
+
+type MatrixSecurity struct {
+ AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+}
+
+type LINESecurity struct {
+ ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+}
+
+type OneBotSecurity struct {
+ AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+}
+
+type WeComSecurity struct {
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+}
+
+type WeComAppSecurity struct {
+ CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+}
+
+type WeComAIBotSecurity struct {
+ Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
+ EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
+}
+
+type PicoSecurity struct {
+ Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
+}
+
+type IRCSecurity struct {
+ Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
+ NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
+ SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+}
+
+type WebToolsSecurity struct {
+ Brave *BraveSecurity `yaml:"brave,omitempty"`
+ Tavily *TavilySecurity `yaml:"tavily,omitempty"`
+ Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"`
+ GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"`
+ BaiduSearch *BaiduSearchSecurity `yaml:"baidu_search,omitempty"`
+}
+
+type BraveSecurity struct {
+ APIKeys []string `yaml:"api_keys,omitempty"`
+}
+
+type TavilySecurity struct {
+ APIKeys []string `yaml:"api_keys,omitempty"`
+}
+
+type PerplexitySecurity struct {
+ APIKeys []string `yaml:"api_keys,omitempty"`
+}
+
+type GLMSearchSecurity struct {
+ APIKey string `yaml:"api_key,omitempty"`
+}
+
+type BaiduSearchSecurity struct {
+ APIKey string `yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"`
+}
+
+type SkillsSecurity struct {
+ Github *GithubSecurity `yaml:"github,omitempty"`
+ ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"`
+}
+
+type GithubSecurity struct {
+ Token string `yaml:"token,omitempty"`
+}
+
+type ClawHubSecurity struct {
+ AuthToken string `yaml:"auth_token,omitempty"`
+}
+
+// securityPath returns the path to security.yml relative to the config file
+func securityPath(configPath string) string {
+ configDir := filepath.Dir(configPath)
+ return filepath.Join(configDir, SecurityConfigFile)
+}
+
+// loadSecurityConfig loads the security configuration from security.yml
+// Returns an empty SecurityConfig if the file doesn't exist
+func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
+ data, err := os.ReadFile(securityPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return &SecurityConfig{}, nil
+ }
+ return nil, fmt.Errorf("failed to read security config: %w", err)
+ }
+
+ var sec SecurityConfig
+ if err := yaml.Unmarshal(data, &sec); err != nil {
+ return nil, fmt.Errorf("failed to parse security config: %w", err)
+ }
+
+ // No need to validate model_name format here - both formats are supported:
+ // - "model-name:0" (with index for multiple entries)
+ // - "model-name" (without index for single entry or default to index 0)
+
+ if err := env.Parse(&sec); err != nil {
+ log.Errorf("failed to parse environment variables: %v", err)
+ return nil, err
+ }
+
+ return &sec, nil
+}
+
+// saveSecurityConfig saves the security configuration to security.yml
+func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ err := enc.Encode(sec)
+ if err != nil {
+ return fmt.Errorf("failed to marshal security config: %w", err)
+ }
+ return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
+}
diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go
new file mode 100644
index 000000000..c1e1a2340
--- /dev/null
+++ b/pkg/config/security_integration_test.go
@@ -0,0 +1,472 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Test JSON unmarshal of private fields
+func TestJSONUnmarshalPrivateFields(t *testing.T) {
+ //nolint: govet
+ type testStruct struct {
+ PublicField string `json:"public"`
+ privateField string `json:"private"`
+ }
+
+ data := `{"public": "pub", "private": "priv"}`
+ var s testStruct
+ if err := json.Unmarshal([]byte(data), &s); err != nil {
+ t.Fatalf("JSON unmarshal failed: %v", err)
+ }
+
+ t.Logf("PublicField: %s", s.PublicField)
+ t.Logf("privateField: %s", s.privateField)
+
+ if s.PublicField != "pub" {
+ t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
+ }
+ // This should fail because privateField is unexported
+ if s.privateField != "priv" {
+ t.Logf("privateField = %q, want 'priv' - THIS IS EXPECTED TO FAIL", s.privateField)
+ }
+}
+
+func TestSecurityConfigIntegration(t *testing.T) {
+ t.Run("Full workflow with security references", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create config.json with references
+ configPath := filepath.Join(tmpDir, "config.json")
+ configContent := `{
+ "version": 1,
+ "model_list": [
+ {
+ "model_name": "test-model",
+ "model": "openai/test-model",
+ "api_base": "https://api.openai.com/v1",
+ "api_key": "ref:model_list.test-model.api_key"
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "ref:channels.telegram.token"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "ref:web.brave.api_key"
+ }
+ },
+ "skills": {
+ "github": {
+ "token": "ref:skills.github.token"
+ }
+ }
+ }
+}`
+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
+ require.NoError(t, err)
+
+ // Create .security.yml with actual values
+ securityPath := filepath.Join(tmpDir, SecurityConfigFile)
+ securityContent := `model_list:
+ test-model:
+ api_keys:
+ - "sk-test-api-key-12345"
+
+channels:
+ telegram:
+ token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
+
+web:
+ brave:
+ api_keys:
+ - "BSAbrave-api-key-67890"
+
+skills:
+ github:
+ token: "ghp_github-token-abc123"`
+ err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
+ require.NoError(t, err)
+
+ // Load config and verify references are resolved
+ cfg, err := LoadConfig(configPath)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Verify model API key is resolved
+ assert.Equal(t, 1, len(cfg.ModelList))
+ assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
+ assert.Equal(t, "sk-test-api-key-12345", cfg.ModelList[0].apiKeys[0])
+
+ // Verify channel token is resolved
+ assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.token)
+
+ // Verify web tool API key is resolved
+ assert.Equal(t, "BSAbrave-api-key-67890", cfg.Tools.Web.Brave.APIKey())
+
+ // Verify skills token is resolved
+ assert.Equal(t, "ghp_github-token-abc123", cfg.Tools.Skills.Github.token)
+ })
+}
+
+func TestSecurityConfigWithAPIKeysArray(t *testing.T) {
+ t.Run("Multiple API keys via security", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create config with APIKeys array
+ configPath := filepath.Join(tmpDir, "config.json")
+ configContent := `{
+ "version": 1,
+ "model_list": [
+ {
+ "model_name": "multi-key-model",
+ "model": "openai/multi-key-model"
+ }
+ ]
+}`
+ err := os.WriteFile(configPath, []byte(configContent), 0o644)
+ require.NoError(t, err)
+
+ // Create .security.yml
+ securityPath := filepath.Join(tmpDir, SecurityConfigFile)
+ securityContent := `model_list:
+ multi-key-model:0:
+ api_key: "sk-key-1"
+ api_keys:
+ - "sk-key-1"
+ - "sk-key-2"
+ - "sk-key-3"
+`
+ err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
+ require.NoError(t, err)
+
+ // Load config
+ cfg, err := LoadConfig(configPath)
+ require.NoError(t, err)
+
+ t.Logf("Config: %+v", cfg.ModelList)
+ for _, m := range cfg.ModelList {
+ t.Logf("Model: %+v", m)
+ }
+ // Verify multi-key expansion works
+ assert.Equal(t, 3, len(cfg.ModelList))
+ assert.Equal(t, "multi-key-model", cfg.ModelList[2].ModelName)
+ })
+}
+
+func TestAllSecurityKeysAccessible(t *testing.T) {
+ t.Run("All security keys accessible via Key() methods including file://", func(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create test files for file:// references
+ modelAPIKeyFile := filepath.Join(tmpDir, "model_api_key.txt")
+ err := os.WriteFile(modelAPIKeyFile, []byte("sk-model-from-file-12345"), 0o600)
+ require.NoError(t, err)
+
+ braveAPIKeyFile := filepath.Join(tmpDir, "brave_api_key.txt")
+ err = os.WriteFile(braveAPIKeyFile, []byte("BSA-brave-from-file-67890"), 0o600)
+ require.NoError(t, err)
+
+ tavilyAPIKeyFile := filepath.Join(tmpDir, "tavily_api_key.txt")
+ err = os.WriteFile(tavilyAPIKeyFile, []byte("tvly-tavily-from-file-11111"), 0o600)
+ require.NoError(t, err)
+
+ perplexityAPIKeyFile := filepath.Join(tmpDir, "perplexity_api_key.txt")
+ err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600)
+ require.NoError(t, err)
+
+ githubTokenFile := filepath.Join(tmpDir, "github_token.txt")
+ err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600)
+ require.NoError(t, err)
+
+ clawhubAuthTokenFile := filepath.Join(tmpDir, "clawhub_auth_token.txt")
+ err = os.WriteFile(clawhubAuthTokenFile, []byte("clawhub-auth-token-from-file"), 0o600)
+ require.NoError(t, err)
+
+ // Create config.json without sensitive values (they'll be in .security.yml)
+ configPath := filepath.Join(tmpDir, "config.json")
+ configContent := `{
+ "version": 1,
+ "model_list": [
+ {
+ "model_name": "test-model-1",
+ "model": "openai/test-model-1"
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true
+ },
+ "feishu": {
+ "enabled": true,
+ "app_id": "test_app_id"
+ },
+ "discord": {
+ "enabled": true
+ },
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "test_client_id"
+ },
+ "slack": {
+ "enabled": true
+ },
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "user_id": "@test:matrix.org"
+ },
+ "line": {
+ "enabled": true,
+ "webhook_host": "localhost",
+ "webhook_port": 8080,
+ "webhook_path": "/webhook"
+ },
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080"
+ },
+ "wecom": {
+ "enabled": true,
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook"
+ },
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "test_corp_id",
+ "agent_id": 123456
+ },
+ "wecom_aibot": {
+ "enabled": true
+ },
+ "pico": {
+ "enabled": true
+ },
+ "irc": {
+ "enabled": true,
+ "server": "irc.example.com",
+ "nick": "testbot"
+ },
+ "qq": {
+ "enabled": true,
+ "app_id": "test_qq_app_id"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true
+ },
+ "tavily": {
+ "enabled": true
+ },
+ "perplexity": {
+ "enabled": true
+ },
+ "glm_search": {
+ "enabled": true
+ }
+ },
+ "skills": {
+ "github": {}
+ }
+ }
+}`
+ err = os.WriteFile(configPath, []byte(configContent), 0o644)
+ require.NoError(t, err)
+
+ // Create .security.yml with file:// references and plaintext values
+ securityPath := filepath.Join(tmpDir, SecurityConfigFile)
+ securityContent := `model_list:
+ test-model-1:
+ api_keys:
+ - "file://model_api_key.txt"
+
+channels:
+ telegram:
+ token: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
+ feishu:
+ app_secret: "feishu_test_app_secret"
+ encrypt_key: "feishu_test_encrypt_key"
+ verification_token: "feishu_test_verification_token"
+ discord:
+ token: "discord_test_bot_token_xyz"
+ dingtalk:
+ client_secret: "dingtalk_test_client_secret"
+ slack:
+ bot_token: "xoxb-slack-bot-token-123"
+ app_token: "xapp-slack-app-token-456"
+ matrix:
+ access_token: "matrix_test_access_token"
+ line:
+ channel_secret: "line_test_channel_secret"
+ channel_access_token: "line_test_channel_access_token"
+ onebot:
+ access_token: "onebot_test_access_token"
+ wecom:
+ token: "wecom_test_webhook_token"
+ encoding_aes_key: "wecom_test_aes_key"
+ wecom_app:
+ corp_secret: "wecom_app_test_corp_secret"
+ token: "wecom_app_test_token"
+ encoding_aes_key: "wecom_app_test_aes_key"
+ wecom_aibot:
+ token: "wecom_aibot_test_token"
+ encoding_aes_key: "wecom_aibot_test_aes_key"
+ pico:
+ token: "pico_test_token"
+ irc:
+ password: "irc_test_password"
+ nickserv_password: "irc_test_nickserv_password"
+ sasl_password: "irc_test_sasl_password"
+ qq:
+ app_secret: "qq_test_app_secret"
+
+web:
+ brave:
+ api_keys:
+ - "file://brave_api_key.txt"
+ tavily:
+ api_keys:
+ - "file://tavily_api_key.txt"
+ perplexity:
+ api_keys:
+ - "file://perplexity_api_key.txt"
+ glm_search:
+ api_key: "glm-test-glm-search-key"
+
+skills:
+ github:
+ token: "file://github_token.txt"
+ clawhub:
+ auth_token: "file://clawhub_auth_token.txt"
+`
+ err = os.WriteFile(securityPath, []byte(securityContent), 0o600)
+ require.NoError(t, err)
+
+ // Load config and verify all security keys are accessible
+ cfg, err := LoadConfig(configPath)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Verify Model API keys
+ assert.Equal(t, 1, len(cfg.ModelList))
+ assert.Equal(t, "test-model-1", cfg.ModelList[0].ModelName)
+ // file:// reference should be resolved
+ assert.Equal(t, "sk-model-from-file-12345", cfg.ModelList[0].APIKey())
+ t.Logf("Model APIKey(): %s", cfg.ModelList[0].APIKey())
+
+ // Verify Channel tokens via Key() methods
+ // Telegram
+ assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token())
+ t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token())
+
+ // Feishu
+ assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret())
+ assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey())
+ assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken())
+ t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret())
+ t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey())
+ t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken())
+
+ // Discord
+ assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token())
+ t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token())
+
+ // DingTalk
+ assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret())
+ t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret())
+
+ // Slack
+ assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken())
+ assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken())
+ t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken())
+ t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken())
+
+ // Matrix
+ assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken())
+ t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken())
+
+ // LINE
+ assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret())
+ assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken())
+ t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret())
+ t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken())
+
+ // OneBot
+ assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken())
+ t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
+
+ // WeCom
+ assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token())
+ assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey())
+ t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token())
+ t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey())
+
+ // WeCom App
+ assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret())
+ assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token())
+ assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey())
+ t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret())
+ t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token())
+ t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey())
+
+ // WeCom AI Bot
+ assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token())
+ assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey())
+ t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token())
+ t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey())
+
+ // Pico
+ assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
+ t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token())
+
+ // IRC
+ assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password())
+ assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword())
+ assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword())
+ t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password())
+ t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword())
+ t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword())
+
+ // QQ
+ assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret())
+ t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret())
+
+ // Verify Web tool API keys
+ assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
+ t.Logf("Brave APIKey(): %s", cfg.Tools.Web.Brave.APIKey())
+
+ assert.Equal(t, "tvly-tavily-from-file-11111", cfg.Tools.Web.Tavily.APIKey())
+ t.Logf("Tavily APIKey(): %s", cfg.Tools.Web.Tavily.APIKey())
+
+ assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey())
+ t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
+
+ // GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
+ t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey())
+ assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey())
+
+ // Verify Skills tokens
+ assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token())
+ t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token())
+
+ assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
+ t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
+
+ t.Log("All security keys are successfully accessible via their respective Key() methods")
+ })
+}
diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go
new file mode 100644
index 000000000..74e765f6b
--- /dev/null
+++ b/pkg/config/security_test.go
@@ -0,0 +1,90 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSecurityConfig(t *testing.T) {
+ t.Run("LoadNonExistent", func(t *testing.T) {
+ sec, err := loadSecurityConfig("/nonexistent/.security.yml")
+ require.NoError(t, err)
+ assert.NotNil(t, sec)
+ assert.Empty(t, sec.ModelList)
+ })
+}
+
+func TestSecurityPath(t *testing.T) {
+ tests := []struct {
+ name string
+ configDir string
+ want string
+ }{
+ {
+ name: "standard path",
+ configDir: "/home/user/.picoclaw/config.json",
+ want: "/home/user/.picoclaw/.security.yml",
+ },
+ {
+ name: "nested path",
+ configDir: "/path/to/config/myconfig.json",
+ want: "/path/to/config/.security.yml",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := securityPath(tt.configDir)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestSaveAndLoadSecurityConfig(t *testing.T) {
+ tmpDir := t.TempDir()
+ secPath := filepath.Join(tmpDir, SecurityConfigFile)
+
+ original := &SecurityConfig{
+ ModelList: map[string]ModelSecurityEntry{
+ "model1:0": {
+ APIKeys: []string{"key1", "key2"},
+ },
+ },
+ Channels: ChannelsSecurity{
+ Telegram: &TelegramSecurity{
+ Token: "telegram-token",
+ },
+ },
+ Web: WebToolsSecurity{
+ Brave: &BraveSecurity{
+ APIKeys: []string{"brave-api-key"},
+ },
+ },
+ }
+
+ // Save
+ err := saveSecurityConfig(secPath, original)
+ require.NoError(t, err)
+
+ // Verify file was created with correct permissions
+ info, err := os.Stat(secPath)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode())
+
+ // Load
+ loaded, err := loadSecurityConfig(secPath)
+ require.NoError(t, err)
+
+ assert.Equal(t, original.ModelList, loaded.ModelList)
+ assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token)
+ assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys)
+}
diff --git a/pkg/env.go b/pkg/env.go
new file mode 100644
index 000000000..b9a77dab2
--- /dev/null
+++ b/pkg/env.go
@@ -0,0 +1,12 @@
+// all environment variables including default values put here
+
+package pkg
+
+const (
+ Logo = "🦞"
+ // AppName is the name of the app
+ AppName = "PicoClaw"
+
+ DefaultPicoClawHome = ".picoclaw"
+ WorkspaceName = "workspace"
+)
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index 92bef6c15..fc2465747 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -47,6 +47,10 @@ const (
serviceShutdownTimeout = 30 * time.Second
providerReloadTimeout = 30 * time.Second
gracefulShutdownTimeout = 15 * time.Second
+
+ logPath = "logs"
+ panicFile = "gateway_panic.log"
+ logFile = "gateway.log"
)
type services struct {
@@ -79,13 +83,25 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
}
// Run starts the gateway runtime using the configuration loaded from configPath.
-func Run(debug bool, configPath string, allowEmptyStartup bool) error {
+func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
+ panicPath := filepath.Join(homePath, logPath, panicFile)
+ panicFunc, err := logger.InitPanic(panicPath)
+ if err != nil {
+ return fmt.Errorf("error initializing panic log: %w", err)
+ }
+ defer panicFunc()
+
+ if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
+ panic(fmt.Sprintf("error enabling file logging: %v", err))
+ }
+ defer logger.DisableFileLogging()
+
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
- logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
+ logger.SetLevelFromString(cfg.Gateway.LogLevel)
if debug {
logger.SetLevel(logger.DEBUG)
@@ -381,9 +397,6 @@ func handleConfigReload(
logger.Info("🔄 Config file changed, reloading...")
newModel := newCfg.Agents.Defaults.ModelName
- if newModel == "" {
- newModel = newCfg.Agents.Defaults.Model
- }
logger.Infof(" New model is '%s', recreating provider...", newModel)
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 179804607..eeb1436de 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -256,6 +256,8 @@ func appendFields(event *zerolog.Event, fields map[string]any) {
for k, v := range fields {
// Type switch to avoid double JSON serialization of strings
switch val := v.(type) {
+ case error:
+ event.Str(k, val.Error())
case string:
event.Str(k, val)
case int:
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
index e551db58e..6ad3a8dd6 100644
--- a/pkg/logger/logger_test.go
+++ b/pkg/logger/logger_test.go
@@ -1,7 +1,12 @@
package logger
import (
+ "bytes"
+ "encoding/json"
+ "errors"
"testing"
+
+ "github.com/rs/zerolog"
)
func TestLogLevelFiltering(t *testing.T) {
@@ -337,3 +342,26 @@ func TestSetLevelFromString(t *testing.T) {
t.Errorf("after SetLevelFromString(\"FATAL\"): GetLevel() = %v, want FATAL", got)
}
}
+
+func TestAppendFields_ErrorUsesErrorString(t *testing.T) {
+ var buf bytes.Buffer
+ l := zerolog.New(&buf)
+
+ event := l.Info()
+ appendFields(event, map[string]any{"error": errors.New("transcription request failed")})
+ event.Msg("test")
+
+ lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n"))
+ if len(lines) == 0 {
+ t.Fatal("expected log output, got none")
+ }
+
+ var got map[string]any
+ if err := json.Unmarshal(lines[0], &got); err != nil {
+ t.Fatalf("unmarshal log line: %v", err)
+ }
+
+ if got["error"] != "transcription request failed" {
+ t.Fatalf("error field = %#v, want %q", got["error"], "transcription request failed")
+ }
+}
diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go
new file mode 100644
index 000000000..e53e4351a
--- /dev/null
+++ b/pkg/logger/panic.go
@@ -0,0 +1,36 @@
+package logger
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime/debug"
+ "time"
+)
+
+func InitPanic(filePath string) (func(), error) {
+ if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create log directory: %w", err)
+ }
+ writer := initPanicFile(filePath)
+ if writer == nil {
+ return nil, fmt.Errorf("failed to create log file: %s", filePath)
+ }
+ return func() {
+ defer writer.Close()
+ if err := recover(); err != nil {
+ now := time.Now().Format("2006-01-02 15:04:05")
+ stack := debug.Stack()
+ logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf(
+ "%v",
+ err,
+ ) + "\n" + string(
+ stack,
+ )
+
+ writer.Write([]byte(logMsg))
+
+ os.Exit(1)
+ }
+ }, nil
+}
diff --git a/pkg/logger/panic_unix.go b/pkg/logger/panic_unix.go
new file mode 100644
index 000000000..48f393b45
--- /dev/null
+++ b/pkg/logger/panic_unix.go
@@ -0,0 +1,22 @@
+//go:build !windows
+
+package logger
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+func initPanicFile(panicFile string) io.WriteCloser {
+ file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND|os.O_SYNC, 0o600)
+ if err != nil {
+ panic(fmt.Sprintf("error in open panic: %v", err))
+ }
+ if err = unix.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil {
+ panic(fmt.Sprintf("error in syscall.Dup2: %v", err))
+ }
+ return file
+}
diff --git a/pkg/logger/panic_win.go b/pkg/logger/panic_win.go
new file mode 100644
index 000000000..29d3f21d8
--- /dev/null
+++ b/pkg/logger/panic_win.go
@@ -0,0 +1,25 @@
+//go:build windows
+// +build windows
+
+package logger
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "golang.org/x/sys/windows"
+)
+
+func initPanicFile(panicFile string) io.WriteCloser {
+ file, err := os.OpenFile(panicFile, os.O_WRONLY|os.O_CREATE|os.O_SYNC|os.O_APPEND, 0600)
+ if err != nil {
+ panic(fmt.Sprintf("error in open panic: %v", err))
+ }
+ err = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(file.Fd()))
+ if err != nil {
+ panic(fmt.Sprintf("Failed to redirect stderr to file: %v", err))
+ }
+ os.Stderr = file
+ return file
+}
diff --git a/pkg/media/store.go b/pkg/media/store.go
index 30220986c..78cff8bb6 100644
--- a/pkg/media/store.go
+++ b/pkg/media/store.go
@@ -11,11 +11,25 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
+// CleanupPolicy controls how the MediaStore treats the underlying file when
+// a ref is released or expires.
+type CleanupPolicy string
+
+const (
+ // CleanupPolicyDeleteOnCleanup means the file is store-managed and may be
+ // deleted once the final ref for that path is gone.
+ CleanupPolicyDeleteOnCleanup CleanupPolicy = "delete_on_cleanup"
+ // CleanupPolicyForgetOnly means the store should only drop ref mappings and
+ // must never delete the underlying file.
+ CleanupPolicyForgetOnly CleanupPolicy = "forget_only"
+)
+
// MediaMeta holds metadata about a stored media file.
type MediaMeta struct {
- Filename string
- ContentType string
- Source string // "telegram", "discord", "tool:image-gen", etc.
+ Filename string
+ ContentType string
+ Source string // "telegram", "discord", "tool:image-gen", etc.
+ CleanupPolicy CleanupPolicy // defaults to CleanupPolicyDeleteOnCleanup
}
// MediaStore manages the lifecycle of media files associated with processing scopes.
@@ -23,6 +37,7 @@ type MediaStore interface {
// Store registers an existing local file under the given scope.
// Returns a ref identifier (e.g. "media://").
// Store does not move or copy the file; it only records the mapping.
+ // If meta.CleanupPolicy is empty, CleanupPolicyDeleteOnCleanup is assumed.
Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
// Resolve returns the local file path for a given ref.
@@ -43,6 +58,11 @@ type mediaEntry struct {
storedAt time.Time
}
+type pathRefState struct {
+ refCount int
+ deleteEligible bool
+}
+
// MediaCleanerConfig configures the background TTL cleanup.
type MediaCleanerConfig struct {
Enabled bool
@@ -57,6 +77,8 @@ type FileMediaStore struct {
refs map[string]mediaEntry
scopeToRefs map[string]map[string]struct{}
refToScope map[string]string
+ refToPath map[string]string
+ pathStates map[string]pathRefState
cleanerCfg MediaCleanerConfig
stop chan struct{}
@@ -71,6 +93,8 @@ func NewFileMediaStore() *FileMediaStore {
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
+ refToPath: make(map[string]string),
+ pathStates: make(map[string]pathRefState),
nowFunc: time.Now,
}
}
@@ -81,6 +105,8 @@ func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
+ refToPath: make(map[string]string),
+ pathStates: make(map[string]pathRefState),
cleanerCfg: cfg,
stop: make(chan struct{}),
nowFunc: time.Now,
@@ -94,6 +120,7 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (
}
ref := "media://" + uuid.New().String()
+ meta.CleanupPolicy = normalizeCleanupPolicy(meta.CleanupPolicy)
s.mu.Lock()
defer s.mu.Unlock()
@@ -104,6 +131,18 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (
}
s.scopeToRefs[scope][ref] = struct{}{}
s.refToScope[ref] = scope
+ s.refToPath[ref] = localPath
+
+ pathState := s.pathStates[localPath]
+ if pathState.refCount == 0 {
+ pathState.deleteEligible = meta.CleanupPolicy == CleanupPolicyDeleteOnCleanup
+ } else if meta.CleanupPolicy == CleanupPolicyForgetOnly {
+ // Be conservative: once a path is borrowed externally, never let this
+ // lifecycle auto-delete it even if store-managed refs also exist.
+ pathState.deleteEligible = false
+ }
+ pathState.refCount++
+ s.pathStates[localPath] = pathState
return ref, nil
}
@@ -134,7 +173,8 @@ func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error)
// ReleaseAll removes all files under the given scope and cleans up mappings.
// Phase 1 (under lock): remove entries from maps.
-// Phase 2 (no lock): delete files from disk.
+// Phase 2 (no lock): delete store-managed files from disk once their final
+// path ref is gone.
func (s *FileMediaStore) ReleaseAll(scope string) error {
// Phase 1: collect paths and remove from maps under lock
var paths []string
@@ -147,11 +187,13 @@ func (s *FileMediaStore) ReleaseAll(scope string) error {
}
for ref := range refs {
+ fallbackPath := ""
if entry, exists := s.refs[ref]; exists {
- paths = append(paths, entry.path)
+ fallbackPath = entry.path
+ }
+ if removablePath, shouldDelete := s.releaseRefLocked(ref, fallbackPath); shouldDelete {
+ paths = append(paths, removablePath)
}
- delete(s.refs, ref)
- delete(s.refToScope, ref)
}
delete(s.scopeToRefs, scope)
s.mu.Unlock()
@@ -171,7 +213,7 @@ func (s *FileMediaStore) ReleaseAll(scope string) error {
// CleanExpired removes all entries older than MaxAge.
// Phase 1 (under lock): identify expired entries and remove from maps.
-// Phase 2 (no lock): delete files from disk to minimize lock contention.
+// Phase 2 (no lock): delete store-managed files from disk to minimize lock contention.
func (s *FileMediaStore) CleanExpired() int {
if s.cleanerCfg.MaxAge <= 0 {
return 0
@@ -179,8 +221,8 @@ func (s *FileMediaStore) CleanExpired() int {
// Phase 1: collect expired entries under lock
type expiredEntry struct {
- ref string
- path string
+ ref string
+ deletePath string
}
s.mu.Lock()
@@ -189,8 +231,6 @@ func (s *FileMediaStore) CleanExpired() int {
for ref, entry := range s.refs {
if entry.storedAt.Before(cutoff) {
- expired = append(expired, expiredEntry{ref: ref, path: entry.path})
-
if scope, ok := s.refToScope[ref]; ok {
if scopeRefs, ok := s.scopeToRefs[scope]; ok {
delete(scopeRefs, ref)
@@ -200,17 +240,23 @@ func (s *FileMediaStore) CleanExpired() int {
}
}
- delete(s.refs, ref)
- delete(s.refToScope, ref)
+ expiredItem := expiredEntry{ref: ref}
+ if deletePath, shouldDelete := s.releaseRefLocked(ref, entry.path); shouldDelete {
+ expiredItem.deletePath = deletePath
+ }
+ expired = append(expired, expiredItem)
}
}
s.mu.Unlock()
// Phase 2: delete files without holding the lock
for _, e := range expired {
- if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) {
+ if e.deletePath == "" {
+ continue
+ }
+ if err := os.Remove(e.deletePath); err != nil && !os.IsNotExist(err) {
logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{
- "path": e.path,
+ "path": e.deletePath,
"error": err.Error(),
})
}
@@ -219,6 +265,45 @@ func (s *FileMediaStore) CleanExpired() int {
return len(expired)
}
+func normalizeCleanupPolicy(policy CleanupPolicy) CleanupPolicy {
+ switch policy {
+ case "", CleanupPolicyDeleteOnCleanup:
+ return CleanupPolicyDeleteOnCleanup
+ case CleanupPolicyForgetOnly:
+ return CleanupPolicyForgetOnly
+ default:
+ return CleanupPolicyDeleteOnCleanup
+ }
+}
+
+func (s *FileMediaStore) releaseRefLocked(ref, fallbackPath string) (string, bool) {
+ path := fallbackPath
+ if storedPath, ok := s.refToPath[ref]; ok {
+ path = storedPath
+ delete(s.refToPath, ref)
+ }
+
+ delete(s.refs, ref)
+ delete(s.refToScope, ref)
+
+ if path == "" {
+ return "", false
+ }
+
+ pathState, ok := s.pathStates[path]
+ if !ok {
+ return "", false
+ }
+ if pathState.refCount <= 1 {
+ delete(s.pathStates, path)
+ return path, pathState.deleteEligible
+ }
+
+ pathState.refCount--
+ s.pathStates[path] = pathState
+ return "", false
+}
+
// Start begins the background cleanup goroutine if cleanup is enabled.
// Safe to call multiple times; only the first call starts the goroutine.
func (s *FileMediaStore) Start() {
diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go
index 1dcfdf350..dabcc3142 100644
--- a/pkg/media/store_test.go
+++ b/pkg/media/store_test.go
@@ -77,6 +77,106 @@ func TestReleaseAll(t *testing.T) {
}
}
+func TestReleaseAllForgetOnlyKeepsFile(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "workspace.txt")
+ ref, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyForgetOnly,
+ }, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ if err := store.ReleaseAll("scope1"); err != nil {
+ t.Fatalf("ReleaseAll failed: %v", err)
+ }
+
+ if _, err := store.Resolve(ref); err == nil {
+ t.Error("forget-only ref should be unresolvable after release")
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("forget-only file should remain on disk: %v", err)
+ }
+}
+
+func TestReleaseAllSharedPathDeletesOnFinalRefOnly(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "shared.jpg")
+ refA, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyDeleteOnCleanup,
+ }, "scopeA")
+ if err != nil {
+ t.Fatalf("Store(scopeA) failed: %v", err)
+ }
+ refB, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyDeleteOnCleanup,
+ }, "scopeB")
+ if err != nil {
+ t.Fatalf("Store(scopeB) failed: %v", err)
+ }
+
+ if err := store.ReleaseAll("scopeA"); err != nil {
+ t.Fatalf("ReleaseAll(scopeA) failed: %v", err)
+ }
+
+ if _, err := store.Resolve(refA); err == nil {
+ t.Error("refA should be unresolvable after ReleaseAll(scopeA)")
+ }
+ if _, err := store.Resolve(refB); err != nil {
+ t.Fatalf("refB should still resolve: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("shared file should remain until final ref is released: %v", err)
+ }
+
+ if err := store.ReleaseAll("scopeB"); err != nil {
+ t.Fatalf("ReleaseAll(scopeB) failed: %v", err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("shared file should be deleted after final ref is released")
+ }
+}
+
+func TestReleaseAllMixedPoliciesKeepsFile(t *testing.T) {
+ dir := t.TempDir()
+ store := NewFileMediaStore()
+
+ path := createTempFile(t, dir, "shared.txt")
+ if _, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyDeleteOnCleanup,
+ }, "owned"); err != nil {
+ t.Fatalf("Store(owned) failed: %v", err)
+ }
+ if _, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyForgetOnly,
+ }, "borrowed"); err != nil {
+ t.Fatalf("Store(borrowed) failed: %v", err)
+ }
+
+ if err := store.ReleaseAll("owned"); err != nil {
+ t.Fatalf("ReleaseAll(owned) failed: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("mixed-policy file should remain after owned ref release: %v", err)
+ }
+
+ if err := store.ReleaseAll("borrowed"); err != nil {
+ t.Fatalf("ReleaseAll(borrowed) failed: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("mixed-policy path should not be auto-deleted: %v", err)
+ }
+}
+
func TestMultiScopeIsolation(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
@@ -293,6 +393,35 @@ func TestCleanExpiredRemovesOldEntries(t *testing.T) {
}
}
+func TestCleanExpiredForgetOnlyKeepsFile(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+ store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
+
+ path := createTempFile(t, dir, "workspace.txt")
+ ref, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyForgetOnly,
+ }, "scope1")
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ store.nowFunc = func() time.Time { return now }
+ removed := store.CleanExpired()
+
+ if removed != 1 {
+ t.Errorf("expected 1 removed, got %d", removed)
+ }
+ if _, err := store.Resolve(ref); err == nil {
+ t.Error("expired forget-only ref should be unresolvable")
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("forget-only file should remain on disk: %v", err)
+ }
+}
+
func TestCleanExpiredKeepsNonExpired(t *testing.T) {
dir := t.TempDir()
now := time.Now()
@@ -346,6 +475,53 @@ func TestCleanExpiredMixedAges(t *testing.T) {
}
}
+func TestCleanExpiredSharedPathDeletesOnFinalRefOnly(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ store := newTestStoreWithCleanup(10 * time.Minute)
+
+ path := createTempFile(t, dir, "shared.jpg")
+
+ store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
+ oldRef, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyDeleteOnCleanup,
+ }, "scope-old")
+ if err != nil {
+ t.Fatalf("Store(old) failed: %v", err)
+ }
+
+ store.nowFunc = func() time.Time { return now }
+ freshRef, err := store.Store(path, MediaMeta{
+ Source: "test",
+ CleanupPolicy: CleanupPolicyDeleteOnCleanup,
+ }, "scope-fresh")
+ if err != nil {
+ t.Fatalf("Store(fresh) failed: %v", err)
+ }
+
+ removed := store.CleanExpired()
+ if removed != 1 {
+ t.Errorf("expected 1 removed, got %d", removed)
+ }
+ if _, err := store.Resolve(oldRef); err == nil {
+ t.Error("old ref should be gone after cleanup")
+ }
+ if _, err := store.Resolve(freshRef); err != nil {
+ t.Fatalf("fresh ref should still resolve: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("shared file should remain while fresh ref exists: %v", err)
+ }
+
+ if err := store.ReleaseAll("scope-fresh"); err != nil {
+ t.Fatalf("ReleaseAll(scope-fresh) failed: %v", err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Error("shared file should be deleted after final ref is released")
+ }
+}
+
func TestCleanExpiredCleansEmptyScopes(t *testing.T) {
dir := t.TempDir()
now := time.Now()
diff --git a/pkg/migrate/internal/common.go b/pkg/migrate/internal/common.go
index 75aef5dc2..65a87adc4 100644
--- a/pkg/migrate/internal/common.go
+++ b/pkg/migrate/internal/common.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
+ "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -20,7 +21,7 @@ func ResolveTargetHome(override string) (string, error) {
if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err)
}
- return filepath.Join(home, ".picoclaw"), nil
+ return filepath.Join(home, pkg.DefaultPicoClawHome), nil
}
func ExpandHome(path string) string {
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
index 317bd3e84..b56194b3d 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -981,13 +981,16 @@ func (c *PicoClawConfig) ToStandardConfig() *config.Config {
cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks
for _, m := range c.ModelList {
- cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
+ mc := &config.ModelConfig{
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
- APIKey: m.APIKey,
Proxy: m.Proxy,
- })
+ }
+ if m.APIKey != "" {
+ mc.SetAPIKey(m.APIKey)
+ }
+ cfg.ModelList = append(cfg.ModelList, mc)
}
cfg.Channels = c.Channels.ToStandardChannels()
@@ -1020,59 +1023,107 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
Enabled: c.WhatsApp.Enabled,
BridgeURL: c.WhatsApp.BridgeURL,
},
- Telegram: config.TelegramConfig{
- Enabled: c.Telegram.Enabled,
- Token: c.Telegram.Token,
- Proxy: c.Telegram.Proxy,
- },
- Feishu: config.FeishuConfig{
- Enabled: c.Feishu.Enabled,
- AppID: c.Feishu.AppID,
- AppSecret: c.Feishu.AppSecret,
- EncryptKey: c.Feishu.EncryptKey,
- VerificationToken: c.Feishu.VerificationToken,
- },
- Discord: config.DiscordConfig{
- Enabled: c.Discord.Enabled,
- Token: c.Discord.Token,
- MentionOnly: c.Discord.MentionOnly,
- },
+ Telegram: func() config.TelegramConfig {
+ tc := config.TelegramConfig{
+ Enabled: c.Telegram.Enabled,
+ Proxy: c.Telegram.Proxy,
+ }
+ if c.Telegram.Token != "" {
+ tc.SetToken(c.Telegram.Token)
+ }
+ return tc
+ }(),
+ Feishu: func() config.FeishuConfig {
+ fc := config.FeishuConfig{
+ Enabled: c.Feishu.Enabled,
+ AppID: c.Feishu.AppID,
+ }
+ if c.Feishu.AppSecret != "" {
+ fc.SetAppSecret(c.Feishu.AppSecret)
+ }
+ if c.Feishu.EncryptKey != "" {
+ fc.SetEncryptKey(c.Feishu.EncryptKey)
+ }
+ if c.Feishu.VerificationToken != "" {
+ fc.SetVerificationToken(c.Feishu.VerificationToken)
+ }
+ return fc
+ }(),
+ Discord: func() config.DiscordConfig {
+ dc := config.DiscordConfig{
+ Enabled: c.Discord.Enabled,
+ MentionOnly: c.Discord.MentionOnly,
+ }
+ if c.Discord.Token != "" {
+ dc.SetToken(c.Discord.Token)
+ }
+ return dc
+ }(),
MaixCam: config.MaixCamConfig{
Enabled: c.MaixCam.Enabled,
Host: c.MaixCam.Host,
Port: c.MaixCam.Port,
},
- QQ: config.QQConfig{
- Enabled: c.QQ.Enabled,
- AppID: c.QQ.AppID,
- AppSecret: c.QQ.AppSecret,
- },
- DingTalk: config.DingTalkConfig{
- Enabled: c.DingTalk.Enabled,
- ClientID: c.DingTalk.ClientID,
- ClientSecret: c.DingTalk.ClientSecret,
- },
- Slack: config.SlackConfig{
- Enabled: c.Slack.Enabled,
- BotToken: c.Slack.BotToken,
- AppToken: c.Slack.AppToken,
- },
- Matrix: config.MatrixConfig{
- Enabled: c.Matrix.Enabled,
- Homeserver: c.Matrix.Homeserver,
- UserID: c.Matrix.UserID,
- AccessToken: c.Matrix.AccessToken,
- AllowFrom: c.Matrix.AllowFrom,
- JoinOnInvite: true,
- },
- LINE: config.LINEConfig{
- Enabled: c.LINE.Enabled,
- ChannelSecret: c.LINE.ChannelSecret,
- ChannelAccessToken: c.LINE.ChannelAccessToken,
- WebhookHost: c.LINE.WebhookHost,
- WebhookPort: c.LINE.WebhookPort,
- WebhookPath: c.LINE.WebhookPath,
- },
+ QQ: func() config.QQConfig {
+ qc := config.QQConfig{
+ Enabled: c.QQ.Enabled,
+ AppID: c.QQ.AppID,
+ }
+ if c.QQ.AppSecret != "" {
+ qc.SetAppSecret(c.QQ.AppSecret)
+ }
+ return qc
+ }(),
+ DingTalk: func() config.DingTalkConfig {
+ dt := config.DingTalkConfig{
+ Enabled: c.DingTalk.Enabled,
+ ClientID: c.DingTalk.ClientID,
+ }
+ if c.DingTalk.ClientSecret != "" {
+ dt.SetClientSecret(c.DingTalk.ClientSecret)
+ }
+ return dt
+ }(),
+ Slack: func() config.SlackConfig {
+ sc := config.SlackConfig{
+ Enabled: c.Slack.Enabled,
+ }
+ if c.Slack.BotToken != "" {
+ sc.SetBotToken(c.Slack.BotToken)
+ }
+ if c.Slack.AppToken != "" {
+ sc.SetAppToken(c.Slack.AppToken)
+ }
+ return sc
+ }(),
+ Matrix: func() config.MatrixConfig {
+ mc := config.MatrixConfig{
+ Enabled: c.Matrix.Enabled,
+ Homeserver: c.Matrix.Homeserver,
+ UserID: c.Matrix.UserID,
+ AllowFrom: c.Matrix.AllowFrom,
+ JoinOnInvite: true,
+ }
+ if c.Matrix.AccessToken != "" {
+ mc.SetAccessToken(c.Matrix.AccessToken)
+ }
+ return mc
+ }(),
+ LINE: func() config.LINEConfig {
+ lc := config.LINEConfig{
+ Enabled: c.LINE.Enabled,
+ WebhookHost: c.LINE.WebhookHost,
+ WebhookPort: c.LINE.WebhookPort,
+ WebhookPath: c.LINE.WebhookPath,
+ }
+ if c.LINE.ChannelSecret != "" {
+ lc.SetChannelSecret(c.LINE.ChannelSecret)
+ }
+ if c.LINE.ChannelAccessToken != "" {
+ lc.SetChannelAccessToken(c.LINE.ChannelAccessToken)
+ }
+ return lc
+ }(),
}
}
@@ -1084,30 +1135,44 @@ func (c GatewayConfig) ToStandardGateway() config.GatewayConfig {
}
func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
+ brave := config.BraveConfig{
+ Enabled: c.Web.Brave.Enabled,
+ MaxResults: c.Web.Brave.MaxResults,
+ }
+ if c.Web.Brave.APIKey != "" {
+ brave.SetAPIKey(c.Web.Brave.APIKey)
+ }
+ if len(c.Web.Brave.APIKeys) > 0 {
+ brave.SetAPIKeys(c.Web.Brave.APIKeys)
+ }
+
+ tavily := config.TavilyConfig{
+ Enabled: c.Web.Tavily.Enabled,
+ BaseURL: c.Web.Tavily.BaseURL,
+ MaxResults: c.Web.Tavily.MaxResults,
+ }
+ if c.Web.Tavily.APIKey != "" {
+ tavily.SetAPIKey(c.Web.Tavily.APIKey)
+ }
+
+ perplexity := config.PerplexityConfig{
+ Enabled: c.Web.Perplexity.Enabled,
+ MaxResults: c.Web.Perplexity.MaxResults,
+ }
+ if c.Web.Perplexity.APIKey != "" {
+ perplexity.SetAPIKey(c.Web.Perplexity.APIKey)
+ }
+
return config.ToolsConfig{
Web: config.WebToolsConfig{
- Brave: config.BraveConfig{
- Enabled: c.Web.Brave.Enabled,
- APIKey: c.Web.Brave.APIKey,
- APIKeys: c.Web.Brave.APIKeys,
- MaxResults: c.Web.Brave.MaxResults,
- },
- Tavily: config.TavilyConfig{
- Enabled: c.Web.Tavily.Enabled,
- APIKey: c.Web.Tavily.APIKey,
- BaseURL: c.Web.Tavily.BaseURL,
- MaxResults: c.Web.Tavily.MaxResults,
- },
+ Brave: brave,
+ Tavily: tavily,
DuckDuckGo: config.DuckDuckGoConfig{
Enabled: c.Web.DuckDuckGo.Enabled,
MaxResults: c.Web.DuckDuckGo.MaxResults,
},
- Perplexity: config.PerplexityConfig{
- Enabled: c.Web.Perplexity.Enabled,
- APIKey: c.Web.Perplexity.APIKey,
- MaxResults: c.Web.Perplexity.MaxResults,
- },
- Proxy: c.Web.Proxy,
+ Perplexity: perplexity,
+ Proxy: c.Web.Proxy,
},
Cron: config.CronToolsConfig{
ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes,
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
index 802693825..350b29776 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config_test.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -697,7 +697,7 @@ func TestToStandardConfig(t *testing.T) {
for _, m := range stdCfg.ModelList {
if m.ModelName == "claude-sonnet-4-20250514" {
foundModel = true
- foundAPIKey = m.APIKey
+ foundAPIKey = m.APIKey()
break
}
}
@@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) {
if !stdCfg.Channels.Telegram.Enabled {
t.Error("telegram should be enabled")
}
- if stdCfg.Channels.Telegram.Token != "test-token" {
- t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token)
+ if stdCfg.Channels.Telegram.Token() != "test-token" {
+ t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token())
}
if stdCfg.Gateway.Port != 8080 {
diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go
index 2b19e941a..6a1c473dd 100644
--- a/pkg/providers/anthropic_messages/provider.go
+++ b/pkg/providers/anthropic_messages/provider.go
@@ -188,17 +188,23 @@ func buildRequestBody(
case "user":
if msg.ToolCallID != "" {
- // Tool result message
- content := []map[string]any{
- {
- "type": "tool_result",
- "tool_use_id": msg.ToolCallID,
- "content": msg.Content,
- },
+ // Tool result message — merge into previous user message if it contains tool_results
+ toolResultBlock := map[string]any{
+ "type": "tool_result",
+ "tool_use_id": msg.ToolCallID,
+ "content": msg.Content,
+ }
+ if len(apiMessages) > 0 {
+ if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" {
+ if content, ok := prev["content"].([]map[string]any); ok {
+ prev["content"] = append(content, toolResultBlock)
+ continue
+ }
+ }
}
apiMessages = append(apiMessages, map[string]any{
"role": "user",
- "content": content,
+ "content": []map[string]any{toolResultBlock},
})
} else {
// Regular user message
@@ -246,17 +252,23 @@ func buildRequestBody(
})
case "tool":
- // Tool result (alternative format)
- content := []map[string]any{
- {
- "type": "tool_result",
- "tool_use_id": msg.ToolCallID,
- "content": msg.Content,
- },
+ // Tool result (alternative format) — merge into previous user message if it contains tool_results
+ toolResultBlock := map[string]any{
+ "type": "tool_result",
+ "tool_use_id": msg.ToolCallID,
+ "content": msg.Content,
+ }
+ if len(apiMessages) > 0 {
+ if prev, ok := apiMessages[len(apiMessages)-1].(map[string]any); ok && prev["role"] == "user" {
+ if content, ok := prev["content"].([]map[string]any); ok {
+ prev["content"] = append(content, toolResultBlock)
+ continue
+ }
+ }
}
apiMessages = append(apiMessages, map[string]any{
"role": "user",
- "content": content,
+ "content": []map[string]any{toolResultBlock},
})
}
}
diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go
index 8eabc15fa..39bc48117 100644
--- a/pkg/providers/anthropic_messages/provider_test.go
+++ b/pkg/providers/anthropic_messages/provider_test.go
@@ -562,6 +562,96 @@ func TestBuildRequestBodyEdgeCases(t *testing.T) {
}
}
+func TestBuildRequestBody_ConsecutiveToolResultsMerged(t *testing.T) {
+ // Consecutive tool results (role "tool") should be merged into a single "user" message
+ messages := []Message{
+ {Role: "user", Content: "Use tools"},
+ {Role: "assistant", Content: "", ToolCalls: []ToolCall{
+ {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}},
+ {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}},
+ }},
+ {Role: "tool", ToolCallID: "t1", Content: "result1"},
+ {Role: "tool", ToolCallID: "t2", Content: "result2"},
+ }
+
+ got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192})
+ if err != nil {
+ t.Fatalf("buildRequestBody() error: %v", err)
+ }
+
+ apiMessages, ok := got["messages"].([]any)
+ if !ok {
+ t.Fatalf("messages is not []any")
+ }
+
+ // Expect: user, assistant, user (merged tool results)
+ if len(apiMessages) != 3 {
+ for i, m := range apiMessages {
+ t.Logf("message[%d]: %+v", i, m)
+ }
+ t.Fatalf("expected 3 API messages, got %d", len(apiMessages))
+ }
+
+ // The third message should be a user message with 2 tool_result blocks
+ toolResultMsg, ok := apiMessages[2].(map[string]any)
+ if !ok {
+ t.Fatalf("tool result message is not map[string]any")
+ }
+ if toolResultMsg["role"] != "user" {
+ t.Errorf("expected role 'user', got %v", toolResultMsg["role"])
+ }
+ content, ok := toolResultMsg["content"].([]map[string]any)
+ if !ok {
+ t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"])
+ }
+ if len(content) != 2 {
+ t.Fatalf("expected 2 tool_result blocks, got %d", len(content))
+ }
+ if content[0]["tool_use_id"] != "t1" {
+ t.Errorf("first tool_result tool_use_id = %v, want t1", content[0]["tool_use_id"])
+ }
+ if content[1]["tool_use_id"] != "t2" {
+ t.Errorf("second tool_result tool_use_id = %v, want t2", content[1]["tool_use_id"])
+ }
+}
+
+func TestBuildRequestBody_UserToolResultsMerged(t *testing.T) {
+ // Consecutive tool results using role "user" with ToolCallID should also be merged
+ messages := []Message{
+ {Role: "user", Content: "Use tools"},
+ {Role: "assistant", Content: "", ToolCalls: []ToolCall{
+ {ID: "t1", Name: "tool_a", Arguments: map[string]any{"x": 1}},
+ {ID: "t2", Name: "tool_b", Arguments: map[string]any{"y": 2}},
+ }},
+ {Role: "user", ToolCallID: "t1", Content: "result1"},
+ {Role: "user", ToolCallID: "t2", Content: "result2"},
+ }
+
+ got, err := buildRequestBody(messages, nil, "test-model", map[string]any{"max_tokens": 8192})
+ if err != nil {
+ t.Fatalf("buildRequestBody() error: %v", err)
+ }
+
+ apiMessages, ok := got["messages"].([]any)
+ if !ok {
+ t.Fatalf("messages is not []any")
+ }
+
+ // Expect: user, assistant, user (merged tool results)
+ if len(apiMessages) != 3 {
+ t.Fatalf("expected 3 API messages, got %d", len(apiMessages))
+ }
+
+ toolResultMsg := apiMessages[2].(map[string]any)
+ content, ok := toolResultMsg["content"].([]map[string]any)
+ if !ok {
+ t.Fatalf("content is not []map[string]any: %T", toolResultMsg["content"])
+ }
+ if len(content) != 2 {
+ t.Fatalf("expected 2 tool_result blocks, got %d", len(content))
+ }
+}
+
// TestParseResponseBodyEdgeCases tests edge cases for parseResponseBody.
func TestParseResponseBodyEdgeCases(t *testing.T) {
tests := []struct {
diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go
index d4d648f5a..bc9960f0c 100644
--- a/pkg/providers/claude_cli_provider_test.go
+++ b/pkg/providers/claude_cli_provider_test.go
@@ -413,10 +413,10 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
func TestCreateProvider_ClaudeCli(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
}
- cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
provider, _, err := CreateProvider(cfg)
if err != nil {
@@ -434,10 +434,10 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
func TestCreateProvider_ClaudeCode(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{ModelName: "claude-code", Model: "claude-cli/claude-code"},
}
- cfg.Agents.Defaults.Model = "claude-code"
+ cfg.Agents.Defaults.ModelName = "claude-code"
provider, _, err := CreateProvider(cfg)
if err != nil {
@@ -450,10 +450,10 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
func TestCreateProvider_ClaudeCodec(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{ModelName: "claudecode", Model: "claude-cli/claudecode"},
}
- cfg.Agents.Defaults.Model = "claudecode"
+ cfg.Agents.Defaults.ModelName = "claudecode"
provider, _, err := CreateProvider(cfg)
if err != nil {
@@ -466,10 +466,10 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
}
- cfg.Agents.Defaults.Model = "claude-cli"
+ cfg.Agents.Defaults.ModelName = "claude-cli"
cfg.Agents.Defaults.Workspace = ""
provider, _, err := CreateProvider(cfg)
diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go
index 9dfd7dc1d..90142fb8b 100644
--- a/pkg/providers/common/common.go
+++ b/pkg/providers/common/common.go
@@ -111,6 +111,17 @@ func SerializeMessages(messages []Message) []any {
"url": mediaURL,
},
})
+ continue
+ }
+
+ if format, data, ok := parseDataAudioURL(mediaURL); ok {
+ parts = append(parts, map[string]any{
+ "type": "input_audio",
+ "input_audio": map[string]any{
+ "data": data,
+ "format": format,
+ },
+ })
}
}
@@ -132,6 +143,26 @@ func SerializeMessages(messages []Message) []any {
return out
}
+func parseDataAudioURL(mediaURL string) (format, data string, ok bool) {
+ if !strings.HasPrefix(mediaURL, "data:audio/") {
+ return "", "", false
+ }
+
+ payload := strings.TrimPrefix(mediaURL, "data:audio/")
+ meta, data, found := strings.Cut(payload, ",")
+ if !found {
+ return "", "", false
+ }
+
+ format, _, _ = strings.Cut(meta, ";")
+ format = strings.TrimSpace(format)
+ data = strings.TrimSpace(data)
+ if format == "" || data == "" {
+ return "", "", false
+ }
+ return format, data, true
+}
+
// --- Response parsing ---
// ParseResponse parses a JSON chat completion response body into an LLMResponse.
diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go
index bb7e7434d..79a637d48 100644
--- a/pkg/providers/common/common_test.go
+++ b/pkg/providers/common/common_test.go
@@ -91,6 +91,44 @@ func TestSerializeMessages_WithMedia(t *testing.T) {
}
}
+func TestSerializeMessages_WithAudioMedia(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "transcribe this", Media: []string{"data:audio/ogg;base64,abc123"}},
+ }
+ result := SerializeMessages(messages)
+
+ data, _ := json.Marshal(result)
+ var msgs []map[string]any
+ json.Unmarshal(data, &msgs)
+
+ content, ok := msgs[0]["content"].([]any)
+ if !ok {
+ t.Fatalf("expected array content for media message, got %T", msgs[0]["content"])
+ }
+ if len(content) != 2 {
+ t.Fatalf("expected 2 content parts, got %d", len(content))
+ }
+
+ audioPart, ok := content[1].(map[string]any)
+ if !ok {
+ t.Fatalf("expected audio content part to be an object, got %T", content[1])
+ }
+ if audioPart["type"] != "input_audio" {
+ t.Fatalf("audio part type = %v, want input_audio", audioPart["type"])
+ }
+
+ inputAudio, ok := audioPart["input_audio"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected input_audio object, got %T", audioPart["input_audio"])
+ }
+ if inputAudio["format"] != "ogg" {
+ t.Fatalf("audio format = %v, want ogg", inputAudio["format"])
+ }
+ if inputAudio["data"] != "abc123" {
+ t.Fatalf("audio data = %v, want abc123", inputAudio["data"])
+ }
+}
+
func TestSerializeMessages_MediaWithToolCallID(t *testing.T) {
messages := []Message{
{Role: "tool", Content: "result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"},
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index d2afe2943..354acafcb 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -1,400 +1,7 @@
package providers
import (
- "fmt"
- "strings"
-
"github.com/sipeed/picoclaw/pkg/auth"
- "github.com/sipeed/picoclaw/pkg/config"
)
-const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
-
var getCredential = auth.GetCredential
-
-type providerType int
-
-const (
- providerTypeHTTPCompat providerType = iota
- providerTypeClaudeAuth
- providerTypeCodexAuth
- providerTypeCodexCLIToken
- providerTypeClaudeCLI
- providerTypeCodexCLI
- providerTypeGitHubCopilot
-)
-
-type providerSelection struct {
- providerType providerType
- apiKey string
- apiBase string
- proxy string
- model string
- workspace string
- connectMode string
- enableWebSearch bool
-}
-
-func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
- model := cfg.Agents.Defaults.GetModelName()
- providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
- lowerModel := strings.ToLower(model)
-
- if providerName == "" && model == "" {
- return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
- }
-
- sel := providerSelection{
- providerType: providerTypeHTTPCompat,
- model: model,
- }
-
- // First, prefer explicit provider configuration.
- if providerName != "" {
- switch providerName {
- case "groq":
- if cfg.Providers.Groq.APIKey != "" {
- sel.apiKey = cfg.Providers.Groq.APIKey
- sel.apiBase = cfg.Providers.Groq.APIBase
- sel.proxy = cfg.Providers.Groq.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.groq.com/openai/v1"
- }
- }
- case "openai", "gpt":
- if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
- sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
- if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
- sel.providerType = providerTypeCodexCLIToken
- return sel, nil
- }
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- sel.providerType = providerTypeCodexAuth
- return sel, nil
- }
- sel.apiKey = cfg.Providers.OpenAI.APIKey
- sel.apiBase = cfg.Providers.OpenAI.APIBase
- sel.proxy = cfg.Providers.OpenAI.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.openai.com/v1"
- }
- }
- case "anthropic", "claude":
- if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- sel.apiBase = cfg.Providers.Anthropic.APIBase
- if sel.apiBase == "" {
- sel.apiBase = defaultAnthropicAPIBase
- }
- sel.providerType = providerTypeClaudeAuth
- return sel, nil
- }
- sel.apiKey = cfg.Providers.Anthropic.APIKey
- sel.apiBase = cfg.Providers.Anthropic.APIBase
- sel.proxy = cfg.Providers.Anthropic.Proxy
- if sel.apiBase == "" {
- sel.apiBase = defaultAnthropicAPIBase
- }
- }
- case "openrouter":
- if cfg.Providers.OpenRouter.APIKey != "" {
- sel.apiKey = cfg.Providers.OpenRouter.APIKey
- sel.proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- sel.apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- sel.apiBase = "https://openrouter.ai/api/v1"
- }
- }
- case "litellm":
- if cfg.Providers.LiteLLM.APIKey != "" || cfg.Providers.LiteLLM.APIBase != "" {
- sel.apiKey = cfg.Providers.LiteLLM.APIKey
- sel.apiBase = cfg.Providers.LiteLLM.APIBase
- sel.proxy = cfg.Providers.LiteLLM.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "http://localhost:4000/v1"
- }
- }
- case "zhipu", "glm":
- if cfg.Providers.Zhipu.APIKey != "" {
- sel.apiKey = cfg.Providers.Zhipu.APIKey
- sel.apiBase = cfg.Providers.Zhipu.APIBase
- sel.proxy = cfg.Providers.Zhipu.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
- }
- case "gemini", "google":
- if cfg.Providers.Gemini.APIKey != "" {
- sel.apiKey = cfg.Providers.Gemini.APIKey
- sel.apiBase = cfg.Providers.Gemini.APIBase
- sel.proxy = cfg.Providers.Gemini.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
- }
- case "vllm":
- if cfg.Providers.VLLM.APIBase != "" {
- sel.apiKey = cfg.Providers.VLLM.APIKey
- sel.apiBase = cfg.Providers.VLLM.APIBase
- sel.proxy = cfg.Providers.VLLM.Proxy
- }
- case "shengsuanyun":
- if cfg.Providers.ShengSuanYun.APIKey != "" {
- sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
- sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
- sel.proxy = cfg.Providers.ShengSuanYun.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://router.shengsuanyun.com/api/v1"
- }
- }
- case "nvidia":
- if cfg.Providers.Nvidia.APIKey != "" {
- sel.apiKey = cfg.Providers.Nvidia.APIKey
- sel.apiBase = cfg.Providers.Nvidia.APIBase
- sel.proxy = cfg.Providers.Nvidia.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://integrate.api.nvidia.com/v1"
- }
- }
- case "vivgrid":
- if cfg.Providers.Vivgrid.APIKey != "" {
- sel.apiKey = cfg.Providers.Vivgrid.APIKey
- sel.apiBase = cfg.Providers.Vivgrid.APIBase
- sel.proxy = cfg.Providers.Vivgrid.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.vivgrid.com/v1"
- }
- }
- case "claude-cli", "claude-code", "claudecode":
- workspace := cfg.WorkspacePath()
- if workspace == "" {
- workspace = "."
- }
- sel.providerType = providerTypeClaudeCLI
- sel.workspace = workspace
- return sel, nil
- case "codex-cli", "codex-code":
- workspace := cfg.WorkspacePath()
- if workspace == "" {
- workspace = "."
- }
- sel.providerType = providerTypeCodexCLI
- sel.workspace = workspace
- return sel, nil
- case "deepseek":
- if cfg.Providers.DeepSeek.APIKey != "" {
- sel.apiKey = cfg.Providers.DeepSeek.APIKey
- sel.apiBase = cfg.Providers.DeepSeek.APIBase
- sel.proxy = cfg.Providers.DeepSeek.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.deepseek.com/v1"
- }
- if model != "deepseek-chat" && model != "deepseek-reasoner" {
- sel.model = "deepseek-chat"
- }
- }
- case "avian":
- if cfg.Providers.Avian.APIKey != "" {
- sel.apiKey = cfg.Providers.Avian.APIKey
- sel.apiBase = cfg.Providers.Avian.APIBase
- sel.proxy = cfg.Providers.Avian.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.avian.io/v1"
- }
- }
- case "mistral":
- if cfg.Providers.Mistral.APIKey != "" {
- sel.apiKey = cfg.Providers.Mistral.APIKey
- sel.apiBase = cfg.Providers.Mistral.APIBase
- sel.proxy = cfg.Providers.Mistral.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.mistral.ai/v1"
- }
- }
- case "minimax":
- if cfg.Providers.Minimax.APIKey != "" {
- sel.apiKey = cfg.Providers.Minimax.APIKey
- sel.apiBase = cfg.Providers.Minimax.APIBase
- sel.proxy = cfg.Providers.Minimax.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.minimaxi.com/v1"
- }
- }
- case "longcat":
- if cfg.Providers.LongCat.APIKey != "" {
- sel.apiKey = cfg.Providers.LongCat.APIKey
- sel.apiBase = cfg.Providers.LongCat.APIBase
- sel.proxy = cfg.Providers.LongCat.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.longcat.chat/openai"
- }
- }
- case "github_copilot", "copilot":
- sel.providerType = providerTypeGitHubCopilot
- if cfg.Providers.GitHubCopilot.APIBase != "" {
- sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
- } else {
- sel.apiBase = "localhost:4321"
- }
- sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
- return sel, nil
- }
- }
-
- // Fallback: infer provider from model and configured keys.
- if sel.apiKey == "" && sel.apiBase == "" {
- switch {
- case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
- sel.apiKey = cfg.Providers.Moonshot.APIKey
- sel.apiBase = cfg.Providers.Moonshot.APIBase
- sel.proxy = cfg.Providers.Moonshot.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.moonshot.cn/v1"
- }
- case strings.HasPrefix(model, "openrouter/") ||
- strings.HasPrefix(model, "anthropic/") ||
- strings.HasPrefix(model, "openai/") ||
- strings.HasPrefix(model, "meta-llama/") ||
- strings.HasPrefix(model, "deepseek/") ||
- strings.HasPrefix(model, "google/"):
- sel.apiKey = cfg.Providers.OpenRouter.APIKey
- sel.proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- sel.apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- sel.apiBase = "https://openrouter.ai/api/v1"
- }
- case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
- (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- sel.apiBase = cfg.Providers.Anthropic.APIBase
- if sel.apiBase == "" {
- sel.apiBase = defaultAnthropicAPIBase
- }
- sel.providerType = providerTypeClaudeAuth
- return sel, nil
- }
- sel.apiKey = cfg.Providers.Anthropic.APIKey
- sel.apiBase = cfg.Providers.Anthropic.APIBase
- sel.proxy = cfg.Providers.Anthropic.Proxy
- if sel.apiBase == "" {
- sel.apiBase = defaultAnthropicAPIBase
- }
- case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
- (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
- sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
- if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
- sel.providerType = providerTypeCodexCLIToken
- return sel, nil
- }
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- sel.providerType = providerTypeCodexAuth
- return sel, nil
- }
- sel.apiKey = cfg.Providers.OpenAI.APIKey
- sel.apiBase = cfg.Providers.OpenAI.APIBase
- sel.proxy = cfg.Providers.OpenAI.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.openai.com/v1"
- }
- case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
- sel.apiKey = cfg.Providers.Gemini.APIKey
- sel.apiBase = cfg.Providers.Gemini.APIBase
- sel.proxy = cfg.Providers.Gemini.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
- case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
- sel.apiKey = cfg.Providers.Zhipu.APIKey
- sel.apiBase = cfg.Providers.Zhipu.APIBase
- sel.proxy = cfg.Providers.Zhipu.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
- case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
- sel.apiKey = cfg.Providers.Groq.APIKey
- sel.apiBase = cfg.Providers.Groq.APIBase
- sel.proxy = cfg.Providers.Groq.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.groq.com/openai/v1"
- }
- case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
- sel.apiKey = cfg.Providers.Nvidia.APIKey
- sel.apiBase = cfg.Providers.Nvidia.APIBase
- sel.proxy = cfg.Providers.Nvidia.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://integrate.api.nvidia.com/v1"
- }
- case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "":
- sel.apiKey = cfg.Providers.Vivgrid.APIKey
- sel.apiBase = cfg.Providers.Vivgrid.APIBase
- sel.proxy = cfg.Providers.Vivgrid.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.vivgrid.com/v1"
- }
- case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
- sel.apiKey = cfg.Providers.Ollama.APIKey
- sel.apiBase = cfg.Providers.Ollama.APIBase
- sel.proxy = cfg.Providers.Ollama.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "http://localhost:11434/v1"
- }
- case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
- sel.apiKey = cfg.Providers.Mistral.APIKey
- sel.apiBase = cfg.Providers.Mistral.APIBase
- sel.proxy = cfg.Providers.Mistral.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.mistral.ai/v1"
- }
- case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "":
- sel.apiKey = cfg.Providers.Minimax.APIKey
- sel.apiBase = cfg.Providers.Minimax.APIBase
- sel.proxy = cfg.Providers.Minimax.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.minimaxi.com/v1"
- }
- case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
- sel.apiKey = cfg.Providers.Avian.APIKey
- sel.apiBase = cfg.Providers.Avian.APIBase
- sel.proxy = cfg.Providers.Avian.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.avian.io/v1"
- }
- case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "":
- sel.apiKey = cfg.Providers.LongCat.APIKey
- sel.apiBase = cfg.Providers.LongCat.APIBase
- sel.proxy = cfg.Providers.LongCat.Proxy
- if sel.apiBase == "" {
- sel.apiBase = "https://api.longcat.chat/openai"
- }
- case cfg.Providers.VLLM.APIBase != "":
- sel.apiKey = cfg.Providers.VLLM.APIKey
- sel.apiBase = cfg.Providers.VLLM.APIBase
- sel.proxy = cfg.Providers.VLLM.Proxy
- default:
- if cfg.Providers.OpenRouter.APIKey != "" {
- sel.apiKey = cfg.Providers.OpenRouter.APIKey
- sel.proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- sel.apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- sel.apiBase = "https://openrouter.ai/api/v1"
- }
- } else {
- return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
- }
- }
- }
-
- if sel.providerType == providerTypeHTTPCompat {
- if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
- return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
- }
- if sel.apiBase == "" {
- return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
- }
- }
-
- return sel, nil
-}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index a7fef8f5b..bc7c2ff70 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -80,7 +80,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return provider, modelID, nil
}
// OpenAI with API key
- if cfg.APIKey == "" && cfg.APIBase == "" {
+ if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
@@ -88,17 +88,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
+ cfg.ExtraBody,
), modelID, nil
case "azure", "azure-openai":
// Azure OpenAI uses deployment-based URLs, api-key header auth,
// and always sends max_completion_tokens.
- if cfg.APIKey == "" {
+ if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for azure protocol")
}
if cfg.APIBase == "" {
@@ -107,7 +108,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
)
}
return azure.NewProviderWithTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
cfg.APIBase,
cfg.Proxy,
cfg.RequestTimeout,
@@ -116,10 +117,10 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
- "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
+ "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
// All other OpenAI-compatible HTTP providers
- if cfg.APIKey == "" && cfg.APIBase == "" {
+ if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
@@ -127,11 +128,37 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase = getDefaultAPIBase(protocol)
}
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
+ cfg.ExtraBody,
+ ), modelID, nil
+
+ case "minimax":
+ // Minimax requires reasoning_split: true in the request body
+ if cfg.APIKey() == "" && cfg.APIBase == "" {
+ return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
+ }
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ extraBody := cfg.ExtraBody
+ if extraBody == nil {
+ extraBody = make(map[string]any)
+ }
+ if _, ok := extraBody["reasoning_split"]; !ok {
+ extraBody["reasoning_split"] = true
+ }
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey(),
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ extraBody,
), modelID, nil
case "anthropic":
@@ -148,15 +175,16 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
- if cfg.APIKey == "" {
+ if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout,
+ cfg.ExtraBody,
), modelID, nil
case "anthropic-messages":
@@ -165,11 +193,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
- if cfg.APIKey == "" {
+ if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
}
return anthropicmessages.NewProviderWithTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
apiBase,
cfg.RequestTimeout,
), modelID, nil
@@ -180,11 +208,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- if cfg.APIKey == "" {
+ if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model)
}
return anthropicmessages.NewProviderWithTimeout(
- cfg.APIKey,
+ cfg.APIKey(),
apiBase,
cfg.RequestTimeout,
), modelID, nil
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 8b9ddeecd..1bff0419d 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -6,6 +6,7 @@
package providers
import (
+ "encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -89,9 +90,9 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-openai",
Model: "openai/gpt-4o",
- APIKey: "test-key",
APIBase: "https://api.example.com/v1",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -129,8 +130,8 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/test-model",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, _, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -155,9 +156,9 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
Model: "litellm/my-proxy-alias",
- APIKey: "test-key",
APIBase: "http://localhost:4000/v1",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -175,9 +176,9 @@ func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
Model: "longcat/LongCat-Flash-Thinking",
- APIKey: "test-key",
APIBase: "https://api.longcat.chat/openai",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -198,9 +199,9 @@ func TestCreateProviderFromConfig_ModelScope(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
- APIKey: "test-key",
APIBase: "https://api-inference.modelscope.cn/v1",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -227,8 +228,8 @@ func TestCreateProviderFromConfig_Novita(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-novita",
Model: "novita/deepseek/deepseek-v3.2",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -255,8 +256,8 @@ func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-anthropic",
Model: "anthropic/claude-sonnet-4.6",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -340,8 +341,8 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-unknown",
Model: "unknown-protocol/model",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
_, _, err := CreateProviderFromConfig(cfg)
if err == nil {
@@ -382,6 +383,7 @@ func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
APIBase: server.URL,
RequestTimeout: 1,
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -411,9 +413,9 @@ func TestCreateProviderFromConfig_Azure(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment",
- APIKey: "test-azure-key",
APIBase: "https://my-resource.openai.azure.com",
}
+ cfg.SetAPIKey("test-azure-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -431,9 +433,9 @@ func TestCreateProviderFromConfig_AzureOpenAIAlias(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "azure-gpt4",
Model: "azure-openai/my-deployment",
- APIKey: "test-azure-key",
APIBase: "https://my-resource.openai.azure.com",
}
+ cfg.SetAPIKey("test-azure-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -464,8 +466,8 @@ func TestCreateProviderFromConfig_AzureMissingAPIBase(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment",
- APIKey: "test-azure-key",
}
+ cfg.SetAPIKey("test-azure-key")
_, _, err := CreateProviderFromConfig(cfg)
if err == nil {
@@ -488,8 +490,8 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/qwen-max",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -522,8 +524,8 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/qwen-max",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -556,8 +558,8 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-" + tt.protocol,
Model: tt.protocol + "/claude-sonnet-4-20250514",
- APIKey: "test-key",
}
+ cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
@@ -603,3 +605,98 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) {
}
}
}
+
+func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) {
+ var requestBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-minimax",
+ Model: "minimax/MiniMax-M2.5",
+ APIBase: server.URL,
+ }
+ cfg.SetAPIKey("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 != "MiniMax-M2.5" {
+ t.Errorf("modelID = %q, want %q", modelID, "MiniMax-M2.5")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ // Verify reasoning_split is automatically injected
+ if got, ok := requestBody["reasoning_split"]; !ok || got != true {
+ t.Fatalf("reasoning_split = %v, want true", got)
+ }
+}
+
+func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
+ var requestBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-minimax-custom",
+ Model: "minimax/MiniMax-M2.5",
+ APIBase: server.URL,
+ ExtraBody: map[string]any{"custom_field": "test"},
+ }
+ cfg.SetAPIKey("test-key")
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ // Verify reasoning_split is automatically injected
+ if got, ok := requestBody["reasoning_split"]; !ok || got != true {
+ t.Fatalf("reasoning_split = %v, want true", got)
+ }
+ // Verify user's custom field is preserved
+ if got, ok := requestBody["custom_field"]; !ok || got != "test" {
+ t.Fatalf("custom_field = %v, want test", got)
+ }
+}
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
index 91469f25b..b99f5baf9 100644
--- a/pkg/providers/factory_test.go
+++ b/pkg/providers/factory_test.go
@@ -1,262 +1,22 @@
package providers
import (
- "strings"
"testing"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
-func TestResolveProviderSelection(t *testing.T) {
- tests := []struct {
- name string
- setup func(*config.Config)
- wantType providerType
- wantAPIBase string
- wantProxy string
- wantErrSubstr string
- }{
- {
- name: "explicit litellm provider uses configured base",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "litellm"
- cfg.Providers.LiteLLM.APIKey = "litellm-key"
- cfg.Providers.LiteLLM.APIBase = "http://localhost:4000/v1"
- cfg.Providers.LiteLLM.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "http://localhost:4000/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "explicit litellm provider defaults base when only key is configured",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "litellm"
- cfg.Providers.LiteLLM.APIKey = "litellm-key"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "http://localhost:4000/v1",
- },
- {
- name: "explicit claude-cli provider routes to cli provider type",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "claude-cli"
- cfg.Agents.Defaults.Workspace = "/tmp/ws"
- },
- wantType: providerTypeClaudeCLI,
- },
- {
- name: "explicit copilot provider routes to github copilot type",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "copilot"
- },
- wantType: providerTypeGitHubCopilot,
- wantAPIBase: "localhost:4321",
- },
- {
- name: "explicit deepseek provider uses deepseek defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "deepseek"
- cfg.Agents.Defaults.Model = "deepseek/deepseek-chat"
- cfg.Providers.DeepSeek.APIKey = "deepseek-key"
- cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.deepseek.com/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "explicit shengsuanyun provider uses defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "shengsuanyun"
- cfg.Providers.ShengSuanYun.APIKey = "ssy-key"
- cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://router.shengsuanyun.com/api/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "explicit nvidia provider uses defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "nvidia"
- cfg.Providers.Nvidia.APIKey = "nvapi-test"
- cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://integrate.api.nvidia.com/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "explicit vivgrid provider uses defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "vivgrid"
- cfg.Providers.Vivgrid.APIKey = "vivgrid-key"
- cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.vivgrid.com/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "openrouter model uses openrouter defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "openrouter/auto"
- cfg.Providers.OpenRouter.APIKey = "sk-or-test"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://openrouter.ai/api/v1",
- },
- {
- name: "anthropic oauth routes to claude auth provider",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
- cfg.Providers.Anthropic.AuthMethod = "oauth"
- },
- wantType: providerTypeClaudeAuth,
- },
- {
- name: "openai oauth routes to codex auth provider",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "gpt-4o"
- cfg.Providers.OpenAI.AuthMethod = "oauth"
- },
- wantType: providerTypeCodexAuth,
- },
- {
- name: "openai codex-cli auth routes to codex cli token provider",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "gpt-4o"
- cfg.Providers.OpenAI.AuthMethod = "codex-cli"
- },
- wantType: providerTypeCodexCLIToken,
- },
- {
- name: "explicit codex-code provider routes to codex cli provider type",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "codex-code"
- cfg.Agents.Defaults.Workspace = "/tmp/ws"
- },
- wantType: providerTypeCodexCLI,
- },
- {
- name: "zhipu model uses zhipu base default",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "glm-4.7"
- cfg.Providers.Zhipu.APIKey = "zhipu-key"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
- },
- {
- name: "groq model uses groq base default",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
- cfg.Providers.Groq.APIKey = "gsk-key"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.groq.com/openai/v1",
- },
- {
- name: "ollama model uses ollama base default",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b"
- cfg.Providers.Ollama.APIKey = "ollama-key"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "http://localhost:11434/v1",
- },
- {
- name: "moonshot model keeps proxy and default base",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
- cfg.Providers.Moonshot.APIKey = "moonshot-key"
- cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.moonshot.cn/v1",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "explicit longcat provider uses defaults",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Provider = "longcat"
- cfg.Providers.LongCat.APIKey = "longcat-key"
- cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.longcat.chat/openai",
- wantProxy: "http://127.0.0.1:7890",
- },
- {
- name: "longcat model fallback uses longcat base default",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking"
- cfg.Providers.LongCat.APIKey = "longcat-key"
- },
- wantType: providerTypeHTTPCompat,
- wantAPIBase: "https://api.longcat.chat/openai",
- },
- {
- name: "missing keys returns model config error",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "custom-model"
- },
- wantErrSubstr: "no API key configured for model",
- },
- {
- name: "openrouter prefix without key returns provider key error",
- setup: func(cfg *config.Config) {
- cfg.Agents.Defaults.Model = "openrouter/auto"
- },
- wantErrSubstr: "no API key configured for provider",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := config.DefaultConfig()
- tt.setup(cfg)
-
- got, err := resolveProviderSelection(cfg)
- if tt.wantErrSubstr != "" {
- if err == nil {
- t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
- }
- if !strings.Contains(err.Error(), tt.wantErrSubstr) {
- t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
- }
- return
- }
-
- if err != nil {
- t.Fatalf("resolveProviderSelection() error = %v", err)
- }
- if got.providerType != tt.wantType {
- t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
- }
- if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
- t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
- }
- if tt.wantProxy != "" && got.proxy != tt.wantProxy {
- t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
- }
- })
- }
-}
-
func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Model = "test-openrouter"
- cfg.ModelList = []config.ModelConfig{
- {
- ModelName: "test-openrouter",
- Model: "openrouter/auto",
- APIKey: "sk-or-test",
- APIBase: "https://openrouter.ai/api/v1",
- },
+ cfg.Agents.Defaults.ModelName = "test-openrouter"
+ modelCfg := &config.ModelConfig{
+ ModelName: "test-openrouter",
+ Model: "openrouter/auto",
+ APIBase: "https://openrouter.ai/api/v1",
}
+ modelCfg.SetAPIKey("sk-or-test")
+ cfg.ModelList = []*config.ModelConfig{modelCfg}
provider, _, err := CreateProvider(cfg)
if err != nil {
@@ -270,8 +30,8 @@ func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Model = "test-codex"
- cfg.ModelList = []config.ModelConfig{
+ cfg.Agents.Defaults.ModelName = "test-codex"
+ cfg.ModelList = []*config.ModelConfig{
{
ModelName: "test-codex",
Model: "codex-cli/codex-model",
@@ -291,8 +51,8 @@ func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Model = "test-claude-cli"
- cfg.ModelList = []config.ModelConfig{
+ cfg.Agents.Defaults.ModelName = "test-claude-cli"
+ cfg.ModelList = []*config.ModelConfig{
{
ModelName: "test-claude-cli",
Model: "claude-cli/claude-sonnet",
@@ -324,8 +84,8 @@ func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
}
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Model = "test-claude-oauth"
- cfg.ModelList = []config.ModelConfig{
+ cfg.Agents.Defaults.ModelName = "test-claude-oauth"
+ cfg.ModelList = []*config.ModelConfig{
{
ModelName: "test-claude-oauth",
Model: "anthropic/claude-sonnet-4.6",
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 803165edb..f2ff52f1d 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -24,12 +24,13 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
- return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
}
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int,
+ extraBody map[string]any,
) *HTTPProvider {
return &HTTPProvider{
delegate: openai_compat.NewProvider(
@@ -38,6 +39,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
proxy,
openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ openai_compat.WithExtraBody(extraBody),
),
}
}
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
index 26905159f..4b0815dd4 100644
--- a/pkg/providers/legacy_provider.go
+++ b/pkg/providers/legacy_provider.go
@@ -18,23 +18,6 @@ import (
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
model := cfg.Agents.Defaults.GetModelName()
- // Ensure model_list is populated from providers config if needed
- // This handles two cases:
- // 1. ModelList is empty - convert all providers
- // 2. ModelList has some entries but not all providers - merge missing ones
- if cfg.HasProvidersConfig() {
- providerModels := config.ConvertProvidersToModelList(cfg)
- existingModelNames := make(map[string]bool)
- for _, m := range cfg.ModelList {
- existingModelNames[m.ModelName] = true
- }
- for _, pm := range providerModels {
- if !existingModelNames[pm.ModelName] {
- cfg.ModelList = append(cfg.ModelList, pm)
- }
- }
- }
-
// Must have model_list at this point
if len(cfg.ModelList) == 0 {
return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config")
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 938e4ea8b..90bc683b8 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -35,6 +35,7 @@ type Provider struct {
apiBase string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client
+ extraBody map[string]any // Additional fields to inject into request body
}
type Option func(*Provider)
@@ -55,6 +56,12 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
+func WithExtraBody(extraBody map[string]any) Option {
+ return func(p *Provider) {
+ p.extraBody = extraBody
+ }
+}
+
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{
apiKey: apiKey,
@@ -140,6 +147,12 @@ func (p *Provider) buildRequestBody(
}
}
+ // Merge extra body fields configured per-provider/model.
+ // These are injected last so they take precedence over defaults.
+ for k, v := range p.extraBody {
+ requestBody[k] = v
+ }
+
return requestBody
}
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index efb03ccb8..ab632ccf3 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -610,6 +610,90 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
}
}
+func TestProviderChat_ExtraBodyInjected(t *testing.T) {
+ var requestBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ extraBody := map[string]any{"reasoning_split": true, "custom_field": "test"}
+ p := NewProvider("key", server.URL, "", WithExtraBody(extraBody))
+
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "minimax/abab7",
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if got, ok := requestBody["reasoning_split"]; !ok || got != true {
+ t.Fatalf("reasoning_split = %v, want true", got)
+ }
+ if got, ok := requestBody["custom_field"]; !ok || got != "test" {
+ t.Fatalf("custom_field = %v, want test", got)
+ }
+}
+
+func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) {
+ var requestBody map[string]any
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ extraBody := map[string]any{"temperature": 0.9}
+ p := NewProvider("key", server.URL, "", WithExtraBody(extraBody))
+
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "gpt-4o",
+ map[string]any{"temperature": 0.5},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ // ExtraBody takes precedence over options since it is merged last.
+ if got := requestBody["temperature"]; got != float64(0.9) {
+ t.Fatalf("temperature = %v, want 0.9 (from extraBody, overriding options)", got)
+ }
+}
+
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go
index 8255db5f9..fdfc899f9 100644
--- a/pkg/routing/route_test.go
+++ b/pkg/routing/route_test.go
@@ -11,7 +11,7 @@ func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *co
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test",
- Model: "gpt-4",
+ ModelName: "gpt-4",
},
List: agents,
},
diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go
index a59ad56aa..44198381e 100644
--- a/pkg/tools/send_file.go
+++ b/pkg/tools/send_file.go
@@ -133,9 +133,10 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
scope := fmt.Sprintf("tool:send_file:%s:%s", channel, chatID)
ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
- Filename: filename,
- ContentType: mediaType,
- Source: "tool:send_file",
+ Filename: filename,
+ ContentType: mediaType,
+ Source: "tool:send_file",
+ CleanupPolicy: media.CleanupPolicyForgetOnly,
}, scope)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to register media: %v", err))
diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go
index cfe5b43e1..f36baf7d0 100644
--- a/pkg/tools/send_file_test.go
+++ b/pkg/tools/send_file_test.go
@@ -107,6 +107,14 @@ func TestSendFileTool_Success(t *testing.T) {
if !result.ResponseHandled {
t.Fatal("expected send_file success to mark response handled")
}
+
+ _, meta, err := store.ResolveWithMeta(result.Media[0])
+ if err != nil {
+ t.Fatalf("ResolveWithMeta failed: %v", err)
+ }
+ if meta.CleanupPolicy != media.CleanupPolicyForgetOnly {
+ t.Errorf("CleanupPolicy = %q, want %q", meta.CleanupPolicy, media.CleanupPolicyForgetOnly)
+ }
}
func TestSendFileTool_CustomFilename(t *testing.T) {
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 42cf79578..7ff724802 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -613,39 +613,124 @@ func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int)
return strings.Join(lines, "\n"), nil
}
+type BaiduSearchProvider struct {
+ apiKey string
+ baseURL string
+ proxy string
+ client *http.Client
+}
+
+func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := p.baseURL
+ if searchURL == "" {
+ searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search"
+ }
+
+ payload := map[string]any{
+ "messages": []map[string]string{
+ {
+ "role": "user",
+ "content": query,
+ },
+ },
+ "search_source": "baidu_search_v2",
+ "resource_type_filter": []map[string]any{{"type": "web", "top_k": count}},
+ }
+
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("baidu search request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("baidu search API error %d: %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ References []struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Content string `json:"content"`
+ } `json:"references"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if len(result.References) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ lines := []string{fmt.Sprintf("Results for: %s (via Baidu Search)", query)}
+ for i, item := range result.References {
+ if i >= count {
+ break
+ }
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Content != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Content))
+ }
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
type WebSearchTool struct {
provider SearchProvider
maxResults int
}
type WebSearchToolOptions struct {
- BraveAPIKeys []string
- BraveMaxResults int
- BraveEnabled bool
- TavilyAPIKeys []string
- TavilyBaseURL string
- TavilyMaxResults int
- TavilyEnabled bool
- DuckDuckGoMaxResults int
- DuckDuckGoEnabled bool
- PerplexityAPIKeys []string
- PerplexityMaxResults int
- PerplexityEnabled bool
- SearXNGBaseURL string
- SearXNGMaxResults int
- SearXNGEnabled bool
- GLMSearchAPIKey string
- GLMSearchBaseURL string
- GLMSearchEngine string
- GLMSearchMaxResults int
- GLMSearchEnabled bool
- Proxy string
+ BraveAPIKeys []string
+ BraveMaxResults int
+ BraveEnabled bool
+ TavilyAPIKeys []string
+ TavilyBaseURL string
+ TavilyMaxResults int
+ TavilyEnabled bool
+ DuckDuckGoMaxResults int
+ DuckDuckGoEnabled bool
+ PerplexityAPIKeys []string
+ PerplexityMaxResults int
+ PerplexityEnabled bool
+ SearXNGBaseURL string
+ SearXNGMaxResults int
+ SearXNGEnabled bool
+ GLMSearchAPIKey string
+ GLMSearchBaseURL string
+ GLMSearchEngine string
+ GLMSearchMaxResults int
+ GLMSearchEnabled bool
+ BaiduSearchAPIKey string
+ BaiduSearchBaseURL string
+ BaiduSearchMaxResults int
+ BaiduSearchEnabled bool
+ Proxy string
}
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
var provider SearchProvider
maxResults := 5
- // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
+ // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
@@ -696,6 +781,20 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
+ } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" {
+ client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err)
+ }
+ provider = &BaiduSearchProvider{
+ apiKey: opts.BaiduSearchAPIKey,
+ baseURL: opts.BaiduSearchBaseURL,
+ proxy: opts.Proxy,
+ client: client,
+ }
+ if opts.BaiduSearchMaxResults > 0 {
+ maxResults = opts.BaiduSearchMaxResults
+ }
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 82e9f5f45..823ca155e 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -1,6 +1,7 @@
package utils
import (
+ "fmt"
"io"
"net/http"
"net/url"
@@ -15,9 +16,21 @@ import (
"github.com/sipeed/picoclaw/pkg/media"
)
+var audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
+
+func AudioFormat(path string) (string, error) {
+ ext := strings.ToLower(filepath.Ext(path))
+ for _, supportedExt := range audioExtensions {
+ if ext == supportedExt {
+ return strings.TrimPrefix(ext, "."), nil
+ }
+ }
+
+ return "", fmt.Errorf("unsupported audio format for %q", path)
+}
+
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
func IsAudioFile(filename, contentType string) bool {
- audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
for _, ext := range audioExtensions {
diff --git a/pkg/voice/audio_model_transcriber.go b/pkg/voice/audio_model_transcriber.go
new file mode 100644
index 000000000..f3ca81961
--- /dev/null
+++ b/pkg/voice/audio_model_transcriber.go
@@ -0,0 +1,95 @@
+package voice
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+type AudioModelTranscriber struct {
+ provider providers.LLMProvider
+ modelID string
+ prompt string
+}
+
+const (
+ defaultTranscriptionPrompt = "Transcribe this audio."
+)
+
+func NewAudioModelTranscriber(modelCfg *config.ModelConfig) *AudioModelTranscriber {
+ if modelCfg == nil {
+ return nil
+ }
+
+ logger.DebugCF("voice", "Creating audio model transcriber", map[string]any{
+ "has_api_key": modelCfg.APIKey() != "",
+ "api_base": modelCfg.APIBase,
+ "model": modelCfg.Model,
+ })
+
+ provider, modelID, err := providers.CreateProviderFromConfig(modelCfg)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to create audio model provider", map[string]any{"error": err})
+ return nil
+ }
+
+ return &AudioModelTranscriber{
+ provider: provider,
+ modelID: modelID,
+ prompt: defaultTranscriptionPrompt,
+ }
+}
+
+func (t *AudioModelTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
+ logger.InfoCF("voice", "Starting audio model transcription", map[string]any{
+ "audio_file": audioFilePath,
+ "model": t.modelID,
+ })
+
+ audioBytes, err := os.ReadFile(audioFilePath)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to read audio file", map[string]any{"path": audioFilePath, "error": err})
+ return nil, fmt.Errorf("failed to read audio file: %w", err)
+ }
+
+ format, err := utils.AudioFormat(audioFilePath)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to detect audio format", map[string]any{"path": audioFilePath, "error": err})
+ return nil, err
+ }
+
+ resp, err := t.provider.Chat(ctx, []providers.Message{
+ {
+ Role: "user",
+ Content: t.prompt,
+ Media: []string{
+ fmt.Sprintf("data:audio/%s;base64,%s", format, base64.StdEncoding.EncodeToString(audioBytes)),
+ },
+ },
+ }, nil, t.modelID, map[string]any{
+ "temperature": 0,
+ })
+ if err != nil {
+ logger.ErrorCF("voice", "Audio model transcription request failed", map[string]any{"error": err})
+ return nil, fmt.Errorf("transcription request failed: %w", err)
+ }
+
+ text := strings.TrimSpace(resp.Content)
+ logger.InfoCF("voice", "Audio model transcription completed successfully", map[string]any{
+ "text_length": len(text),
+ "transcription_preview": utils.Truncate(text, 50),
+ })
+
+ return &TranscriptionResponse{Text: text}, nil
+}
+
+func (t *AudioModelTranscriber) Name() string {
+ return "audio-model"
+}
diff --git a/pkg/voice/audio_model_transcriber_test.go b/pkg/voice/audio_model_transcriber_test.go
new file mode 100644
index 000000000..c33e3bf97
--- /dev/null
+++ b/pkg/voice/audio_model_transcriber_test.go
@@ -0,0 +1,203 @@
+package voice
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+var _ Transcriber = (*AudioModelTranscriber)(nil)
+
+type fakeLLMProvider struct {
+ chatFunc func(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*providers.LLMResponse, error)
+}
+
+func (p *fakeLLMProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+) (*providers.LLMResponse, error) {
+ if p.chatFunc == nil {
+ return nil, nil
+ }
+ return p.chatFunc(ctx, messages, tools, model, options)
+}
+
+func (p *fakeLLMProvider) GetDefaultModel() string {
+ return ""
+}
+
+func TestAudioModelTranscriberName(t *testing.T) {
+ tr := &AudioModelTranscriber{}
+ if got := tr.Name(); got != "audio-model" {
+ t.Errorf("Name() = %q, want %q", got, "audio-model")
+ }
+}
+
+func TestNewAudioModelTranscriberInvalidConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *config.ModelConfig
+ }{
+ {
+ name: "nil config",
+ cfg: nil,
+ },
+ {
+ name: "missing api key",
+ cfg: &config.ModelConfig{
+ Model: "gemini/gemini-2.5-flash",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tr := NewAudioModelTranscriber(tt.cfg); tr != nil {
+ t.Fatalf("NewAudioModelTranscriber() = %#v, want nil", tr)
+ }
+ })
+ }
+}
+
+func TestAudioModelTranscriberTranscribe(t *testing.T) {
+ tmpDir := t.TempDir()
+ audioPath := filepath.Join(tmpDir, "clip.ogg")
+ audioData := []byte("fake-audio-data")
+ if err := os.WriteFile(audioPath, audioData, 0o644); err != nil {
+ t.Fatalf("failed to write fake audio file: %v", err)
+ }
+
+ t.Run("success", func(t *testing.T) {
+ tr := &AudioModelTranscriber{
+ provider: &fakeLLMProvider{
+ chatFunc: func(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*providers.LLMResponse, error) {
+ if ctx == nil {
+ t.Fatal("context should not be nil")
+ }
+ if tools != nil {
+ t.Fatalf("tools = %#v, want nil", tools)
+ }
+ if model != "gemini-2.5-flash" {
+ t.Fatalf("model = %q, want %q", model, "gemini-2.5-flash")
+ }
+ if len(messages) != 1 {
+ t.Fatalf("len(messages) = %d, want 1", len(messages))
+ }
+ msg := messages[0]
+ if msg.Role != "user" {
+ t.Fatalf("role = %q, want %q", msg.Role, "user")
+ }
+ if msg.Content != defaultTranscriptionPrompt {
+ t.Fatalf("prompt = %q, want %q", msg.Content, defaultTranscriptionPrompt)
+ }
+ if len(msg.Media) != 1 {
+ t.Fatalf("len(media) = %d, want 1", len(msg.Media))
+ }
+ wantMedia := "data:audio/ogg;base64," + base64.StdEncoding.EncodeToString(audioData)
+ if msg.Media[0] != wantMedia {
+ t.Fatalf("media = %q, want %q", msg.Media[0], wantMedia)
+ }
+ if len(options) != 1 {
+ t.Fatalf("options = %#v, want only temperature", options)
+ }
+ if got := options["temperature"]; got != 0 {
+ t.Fatalf("temperature = %#v, want 0", got)
+ }
+
+ return &providers.LLMResponse{Content: " hello from gemini \n"}, nil
+ },
+ },
+ modelID: "gemini-2.5-flash",
+ prompt: defaultTranscriptionPrompt,
+ }
+
+ resp, err := tr.Transcribe(context.Background(), audioPath)
+ if err != nil {
+ t.Fatalf("Transcribe() error: %v", err)
+ }
+ if resp.Text != "hello from gemini" {
+ t.Fatalf("Text = %q, want %q", resp.Text, "hello from gemini")
+ }
+ })
+
+ t.Run("provider error", func(t *testing.T) {
+ tr := &AudioModelTranscriber{
+ provider: &fakeLLMProvider{
+ chatFunc: func(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ options map[string]any,
+ ) (*providers.LLMResponse, error) {
+ return nil, errors.New("upstream failure")
+ },
+ },
+ modelID: "gemini-2.5-flash",
+ prompt: defaultTranscriptionPrompt,
+ }
+
+ _, err := tr.Transcribe(context.Background(), audioPath)
+ if err == nil {
+ t.Fatal("expected error for provider failure, got nil")
+ }
+ if got := err.Error(); got != "transcription request failed: upstream failure" {
+ t.Fatalf("error = %q, want %q", got, "transcription request failed: upstream failure")
+ }
+ })
+
+ t.Run("missing file", func(t *testing.T) {
+ tr := &AudioModelTranscriber{
+ provider: &fakeLLMProvider{},
+ modelID: "gemini-2.5-flash",
+ prompt: defaultTranscriptionPrompt,
+ }
+
+ _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
+ if err == nil {
+ t.Fatal("expected error for missing file, got nil")
+ }
+ })
+
+ t.Run("unsupported audio format", func(t *testing.T) {
+ badPath := filepath.Join(tmpDir, "clip.txt")
+ if err := os.WriteFile(badPath, []byte("not-audio"), 0o644); err != nil {
+ t.Fatalf("failed to write fake file: %v", err)
+ }
+
+ tr := &AudioModelTranscriber{
+ provider: &fakeLLMProvider{},
+ modelID: "gemini-2.5-flash",
+ prompt: defaultTranscriptionPrompt,
+ }
+
+ _, err := tr.Transcribe(context.Background(), badPath)
+ if err == nil {
+ t.Fatal("expected error for unsupported audio format, got nil")
+ }
+ if got := err.Error(); got != `unsupported audio format for "`+badPath+`"` {
+ t.Fatalf("error = %q, want unsupported format error", got)
+ }
+ })
+}
diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go
new file mode 100644
index 000000000..b42e598f7
--- /dev/null
+++ b/pkg/voice/groq_transcriber.go
@@ -0,0 +1,151 @@
+package voice
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+type GroqTranscriber struct {
+ apiKey string
+ apiBase string
+ httpClient *http.Client
+}
+
+func NewGroqTranscriber(apiKey string) *GroqTranscriber {
+ logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
+
+ apiBase := "https://api.groq.com/openai/v1"
+ return &GroqTranscriber{
+ apiKey: apiKey,
+ apiBase: apiBase,
+ httpClient: &http.Client{
+ Timeout: 60 * time.Second,
+ },
+ }
+}
+
+func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
+ logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
+
+ audioFile, err := os.Open(audioFilePath)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
+ return nil, fmt.Errorf("failed to open audio file: %w", err)
+ }
+ defer audioFile.Close()
+
+ fileInfo, err := audioFile.Stat()
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
+ return nil, fmt.Errorf("failed to get file info: %w", err)
+ }
+
+ logger.DebugCF("voice", "Audio file details", map[string]any{
+ "size_bytes": fileInfo.Size(),
+ "file_name": filepath.Base(audioFilePath),
+ })
+
+ var requestBody bytes.Buffer
+ writer := multipart.NewWriter(&requestBody)
+
+ part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to create form file: %w", err)
+ }
+
+ copied, err := io.Copy(part, audioFile)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to copy file content: %w", err)
+ }
+
+ logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
+
+ if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
+ logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to write model field: %w", err)
+ }
+
+ if err = writer.WriteField("response_format", "json"); err != nil {
+ logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to write response_format field: %w", err)
+ }
+
+ if err = writer.Close(); err != nil {
+ logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to close multipart writer: %w", err)
+ }
+
+ url := t.apiBase + "/audio/transcriptions"
+ req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+t.apiKey)
+
+ logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
+ "url": url,
+ "request_size_bytes": requestBody.Len(),
+ "file_size_bytes": fileInfo.Size(),
+ })
+
+ resp, err := t.httpClient.Do(req)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ logger.ErrorCF("voice", "API error", map[string]any{
+ "status_code": resp.StatusCode,
+ "response": string(body),
+ })
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ logger.DebugCF("voice", "Received response from Groq API", map[string]any{
+ "status_code": resp.StatusCode,
+ "response_size_bytes": len(body),
+ })
+
+ var result TranscriptionResponse
+ if err := json.Unmarshal(body, &result); err != nil {
+ logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
+ "text_length": len(result.Text),
+ "language": result.Language,
+ "duration_seconds": result.Duration,
+ "transcription_preview": utils.Truncate(result.Text, 50),
+ })
+
+ return &result, nil
+}
+
+func (t *GroqTranscriber) Name() string {
+ return "groq"
+}
diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go
new file mode 100644
index 000000000..fdcaa7580
--- /dev/null
+++ b/pkg/voice/groq_transcriber_test.go
@@ -0,0 +1,84 @@
+package voice
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+var _ Transcriber = (*GroqTranscriber)(nil)
+
+func TestGroqTranscriberName(t *testing.T) {
+ tr := NewGroqTranscriber("sk-test")
+ if got := tr.Name(); got != "groq" {
+ t.Errorf("Name() = %q, want %q", got, "groq")
+ }
+}
+
+func TestGroqTranscribe(t *testing.T) {
+ // Write a minimal fake audio file so the transcriber can open and send it.
+ tmpDir := t.TempDir()
+ audioPath := filepath.Join(tmpDir, "clip.ogg")
+ if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
+ t.Fatalf("failed to write fake audio file: %v", err)
+ }
+
+ t.Run("success", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/audio/transcriptions" {
+ t.Errorf("unexpected path: %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Bearer sk-test" {
+ t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(TranscriptionResponse{
+ Text: "hello world",
+ Language: "en",
+ Duration: 1.5,
+ })
+ }))
+ defer srv.Close()
+
+ tr := NewGroqTranscriber("sk-test")
+ tr.apiBase = srv.URL
+
+ resp, err := tr.Transcribe(context.Background(), audioPath)
+ if err != nil {
+ t.Fatalf("Transcribe() error: %v", err)
+ }
+ if resp.Text != "hello world" {
+ t.Errorf("Text = %q, want %q", resp.Text, "hello world")
+ }
+ if resp.Language != "en" {
+ t.Errorf("Language = %q, want %q", resp.Language, "en")
+ }
+ })
+
+ t.Run("api error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+
+ tr := NewGroqTranscriber("sk-bad")
+ tr.apiBase = srv.URL
+
+ _, err := tr.Transcribe(context.Background(), audioPath)
+ if err == nil {
+ t.Fatal("expected error for non-200 response, got nil")
+ }
+ })
+
+ t.Run("missing file", func(t *testing.T) {
+ tr := NewGroqTranscriber("sk-test")
+ _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
+ if err == nil {
+ t.Fatal("expected error for missing file, got nil")
+ }
+ })
+}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
index e949d7a22..a50fba8f8 100644
--- a/pkg/voice/transcriber.go
+++ b/pkg/voice/transcriber.go
@@ -1,21 +1,11 @@
package voice
import (
- "bytes"
"context"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
"strings"
- "time"
"github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
type Transcriber interface {
@@ -23,157 +13,51 @@ type Transcriber interface {
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
}
-type GroqTranscriber struct {
- apiKey string
- apiBase string
- httpClient *http.Client
-}
-
type TranscriptionResponse struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
-func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
+func supportsAudioTranscription(model string) bool {
+ protocol, _ := providers.ExtractProtocol(model)
- apiBase := "https://api.groq.com/openai/v1"
- return &GroqTranscriber{
- apiKey: apiKey,
- apiBase: apiBase,
- httpClient: &http.Client{
- Timeout: 60 * time.Second,
- },
+ switch protocol {
+ case "openai", "azure", "azure-openai",
+ "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
+ "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":
+ // These protocols all go through the OpenAI-compatible or Azure provider path in
+ // providers.CreateProviderFromConfig, so they are the only ones that can supply
+ // the audio media payload shape expected by NewAudioModelTranscriber.
+
+ // TODO: Further restrict this by modelID, since not every model under these
+ // protocols supports audio transcription.
+ return true
+ default:
+ return false
}
}
-func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
-
- audioFile, err := os.Open(audioFilePath)
- if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to open audio file: %w", err)
- }
- defer audioFile.Close()
-
- fileInfo, err := audioFile.Stat()
- if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to get file info: %w", err)
- }
-
- logger.DebugCF("voice", "Audio file details", map[string]any{
- "size_bytes": fileInfo.Size(),
- "file_name": filepath.Base(audioFilePath),
- })
-
- var requestBody bytes.Buffer
- writer := multipart.NewWriter(&requestBody)
-
- part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
- if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create form file: %w", err)
- }
-
- copied, err := io.Copy(part, audioFile)
- if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to copy file content: %w", err)
- }
-
- logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
-
- if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
- logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write model field: %w", err)
- }
-
- if err = writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write response_format field: %w", err)
- }
-
- if err = writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to close multipart writer: %w", err)
- }
-
- url := t.apiBase + "/audio/transcriptions"
- req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
- if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", writer.FormDataContentType())
- req.Header.Set("Authorization", "Bearer "+t.apiKey)
-
- logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
- "url": url,
- "request_size_bytes": requestBody.Len(),
- "file_size_bytes": fileInfo.Size(),
- })
-
- resp, err := t.httpClient.Do(req)
- if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]any{
- "status_code": resp.StatusCode,
- "response": string(body),
- })
- return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- logger.DebugCF("voice", "Received response from Groq API", map[string]any{
- "status_code": resp.StatusCode,
- "response_size_bytes": len(body),
- })
-
- var result TranscriptionResponse
- if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
- "text_length": len(result.Text),
- "language": result.Language,
- "duration_seconds": result.Duration,
- "transcription_preview": utils.Truncate(result.Text, 50),
- })
-
- return &result, nil
-}
-
-func (t *GroqTranscriber) Name() string {
- return "groq"
-}
-
// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
// nil if no supported transcription provider is configured.
func DetectTranscriber(cfg *config.Config) Transcriber {
- // Direct Groq provider config takes priority.
- if key := cfg.Providers.Groq.APIKey; key != "" {
- return NewGroqTranscriber(key)
+ if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
+ modelCfg, err := cfg.GetModelConfig(modelName)
+ if err != nil {
+ return nil
+ }
+ if supportsAudioTranscription(modelCfg.Model) {
+ return NewAudioModelTranscriber(modelCfg)
+ }
}
+
// Fall back to any model-list entry that uses the groq/ protocol.
for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
- return NewGroqTranscriber(mc.APIKey)
+ if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
+ return NewGroqTranscriber(mc.APIKey())
}
}
return nil
diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go
index 9b6add333..20ba5388b 100644
--- a/pkg/voice/transcriber_test.go
+++ b/pkg/voice/transcriber_test.go
@@ -1,27 +1,11 @@
package voice
import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
-// Ensure GroqTranscriber satisfies the Transcriber interface at compile time.
-var _ Transcriber = (*GroqTranscriber)(nil)
-
-func TestGroqTranscriberName(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- if got := tr.Name(); got != "groq" {
- t.Errorf("Name() = %q, want %q", got, "groq")
- }
-}
-
func TestDetectTranscriber(t *testing.T) {
tests := []struct {
name string
@@ -35,45 +19,132 @@ func TestDetectTranscriber(t *testing.T) {
wantNil: true,
},
{
- name: "groq provider key",
- cfg: &config.Config{
- Providers: config.ProvidersConfig{
- Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
+ name: "voice model name selects audio model transcriber",
+ cfg: (&config.Config{
+ Voice: config.VoiceConfig{ModelName: "voice-gemini"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
},
- },
- wantName: "groq",
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "voice-gemini": {
+ APIKeys: []string{"sk-gemini-model"},
+ },
+ },
+ }),
+ wantName: "audio-model",
},
{
name: "groq via model list",
- cfg: &config.Config{
- ModelList: []config.ModelConfig{
- {Model: "openai/gpt-4o", APIKey: "sk-openai"},
- {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
+ cfg: (&config.Config{
+ ModelList: []*config.ModelConfig{
+ {ModelName: "openai", Model: "openai/gpt-4o"},
+ {ModelName: "groq", Model: "groq/llama-3.3-70b"},
},
- },
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "openai": {
+ APIKeys: []string{"sk-openai"},
+ },
+ "groq": {
+ APIKeys: []string{"sk-groq-model"},
+ },
+ },
+ }),
wantName: "groq",
},
+ {
+ name: "voice model name selects non-gemini audio model transcriber",
+ cfg: (&config.Config{
+ Voice: config.VoiceConfig{ModelName: "voice-openai-audio"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"},
+ },
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "voice-openai-audio": {
+ APIKeys: []string{"sk-openai"},
+ },
+ },
+ }),
+ wantName: "audio-model",
+ },
+ {
+ name: "voice model name selects azure audio model transcriber",
+ cfg: (&config.Config{
+ Voice: config.VoiceConfig{ModelName: "voice-azure-audio"},
+ ModelList: []*config.ModelConfig{
+ {
+ ModelName: "voice-azure-audio",
+ Model: "azure/my-audio-deployment",
+ APIBase: "https://example.openai.azure.com",
+ },
+ },
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "voice-azure-audio": {
+ APIKeys: []string{"sk-azure"},
+ },
+ },
+ }),
+ wantName: "audio-model",
+ },
+ {
+ name: "voice model name with non openai compatible protocol does not select audio model transcriber",
+ cfg: (&config.Config{
+ Voice: config.VoiceConfig{ModelName: "voice-anthropic"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"},
+ },
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "voice-anthropic": {
+ APIKeys: []string{"sk-anthropic"},
+ },
+ },
+ }),
+ wantNil: true,
+ },
{
name: "groq model list entry without key is skipped",
cfg: &config.Config{
- ModelList: []config.ModelConfig{
- {Model: "groq/llama-3.3-70b", APIKey: ""},
+ ModelList: []*config.ModelConfig{
+ {Model: "groq/llama-3.3-70b"},
},
},
wantNil: true,
},
{
name: "provider key takes priority over model list",
- cfg: &config.Config{
- Providers: config.ProvidersConfig{
- Groq: config.ProviderConfig{APIKey: "sk-groq-direct"},
+ cfg: (&config.Config{
+ ModelList: []*config.ModelConfig{
+ {ModelName: "groq", Model: "groq/llama-3.3-70b"},
},
- ModelList: []config.ModelConfig{
- {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"},
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "groq": {
+ APIKeys: []string{"sk-groq-model"},
+ },
},
- },
+ }),
wantName: "groq",
},
+ {
+ name: "missing voice model name config returns nil",
+ cfg: (&config.Config{
+ Voice: config.VoiceConfig{ModelName: "missing"},
+ ModelList: []*config.ModelConfig{
+ {ModelName: "other", Model: "gemini/gemini-2.5-flash"},
+ },
+ }).WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "other": {
+ APIKeys: []string{"sk-other-model"},
+ },
+ },
+ }),
+ wantNil: true,
+ },
}
for _, tc := range tests {
@@ -94,67 +165,3 @@ func TestDetectTranscriber(t *testing.T) {
})
}
}
-
-func TestTranscribe(t *testing.T) {
- // Write a minimal fake audio file so the transcriber can open and send it.
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/audio/transcriptions" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "Bearer sk-test" {
- t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello world",
- Language: "en",
- Duration: 1.5,
- })
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello world" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello world")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index a7d5b3c5d..7cdfde174 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -8,6 +8,7 @@ import (
"regexp"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
@@ -45,7 +46,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var cfg config.Config
- if err := json.Unmarshal(body, &cfg); err != nil {
+ if err = json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
@@ -63,6 +64,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
+ logger.Infof("new config: %+v", cfg)
+ oldCfg, err := config.LoadConfig(h.configPath)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
+ return
+ }
+ cfg.SecurityCopyFrom(oldCfg)
+
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
@@ -150,6 +159,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return
}
+ newCfg.SecurityCopyFrom(cfg)
+
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
@@ -175,17 +186,17 @@ func validateConfig(cfg *config.Config) []string {
}
// Pico channel: token required when enabled
- if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" {
+ if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
// Telegram: token required when enabled
- if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
+ if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
// Discord: token required when enabled
- if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
+ if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 54ec8e857..bbf285e14 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -18,6 +18,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
+"version": 1,
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace"
@@ -27,7 +28,7 @@ func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testin
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
- "api_key": "sk-default"
+ "api_keys": ["sk-default"]
}
]
}`))
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index d5ccd6e29..7f72f12b8 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -159,10 +159,10 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
}
- if !hasModelConfiguration(*modelCfg) {
+ if !hasModelConfiguration(modelCfg) {
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
}
- if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) {
+ if requiresRuntimeProbe(modelCfg) && !probeLocalModelAvailability(modelCfg) {
return false, fmt.Sprintf("default model %q is not reachable", modelName), nil
}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index 504d091af..a5ba2bad2 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -101,7 +101,7 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Model = "missing-model"
+ cfg.Agents.Defaults.ModelName = "missing-model"
err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
@@ -124,7 +124,7 @@ func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = "test-key"
+ cfg.ModelList[0].SetAPIKey("test-key")
err := config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
@@ -144,7 +144,7 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = ""
+ cfg.ModelList[0].SetAPIKey("")
cfg.ModelList[0].AuthMethod = ""
err := config.SaveConfig(configPath, cfg)
if err != nil {
@@ -169,7 +169,7 @@ func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
defer cleanup()
resetModelProbeHooks(t)
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
return false
}
@@ -177,7 +177,7 @@ func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
@@ -206,15 +206,15 @@ func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
defer cleanup()
resetModelProbeHooks(t)
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
- return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == ""
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "local-vllm",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
@@ -240,7 +240,7 @@ func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
defer cleanup()
resetModelProbeHooks(t)
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID)
return false
}
@@ -249,12 +249,12 @@ func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "remote-vllm",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
- APIKey: "remote-key",
}}
+ cfg.ModelList[0o0].SetAPIKey("remote-key")
cfg.Agents.Defaults.ModelName = "remote-vllm"
err = config.SaveConfig(configPath, cfg)
if err != nil {
@@ -284,7 +284,7 @@ func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "local-ollama",
Model: "ollama/llama3",
}}
@@ -312,7 +312,7 @@ func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "openai-oauth",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
@@ -483,12 +483,12 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = "test-key"
- cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
+ cfg.ModelList[0].SetAPIKey("test-key")
+ cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: "second-model",
Model: "openai/gpt-4.1",
- APIKey: "second-key",
})
+ cfg.ModelList[len(cfg.ModelList)-1].SetAPIKey("second-key")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -632,7 +632,7 @@ func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = ""
+ cfg.ModelList[0].SetAPIKey("")
cfg.ModelList[0].AuthMethod = ""
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
@@ -685,7 +685,7 @@ func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = "test-key"
+ cfg.ModelList[0].SetAPIKey("test-key")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -751,7 +751,7 @@ func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
- cfg.ModelList[0].APIKey = "test-key"
+ cfg.ModelList[0].SetAPIKey("test-key")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
index 22bf5c15b..aeef85119 100644
--- a/web/backend/api/model_status.go
+++ b/web/backend/api/model_status.go
@@ -20,9 +20,9 @@ var (
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
)
-func hasModelConfiguration(m config.ModelConfig) bool {
+func hasModelConfiguration(m *config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
- apiKey := strings.TrimSpace(m.APIKey)
+ apiKey := strings.TrimSpace(m.APIKey())
if authMethod == "oauth" || authMethod == "token" {
if provider, ok := oauthProviderForModel(m.Model); ok {
@@ -44,7 +44,7 @@ func hasModelConfiguration(m config.ModelConfig) bool {
// isModelConfigured reports whether a model is currently available to use.
// Local models must be reachable; remote/API-key models only need saved config.
-func isModelConfigured(m config.ModelConfig) bool {
+func isModelConfigured(m *config.ModelConfig) bool {
if !hasModelConfiguration(m) {
return false
}
@@ -54,7 +54,7 @@ func isModelConfigured(m config.ModelConfig) bool {
return true
}
-func requiresRuntimeProbe(m config.ModelConfig) bool {
+func requiresRuntimeProbe(m *config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
if authMethod == "local" {
return true
@@ -75,27 +75,27 @@ func requiresRuntimeProbe(m config.ModelConfig) bool {
return false
}
-func probeLocalModelAvailability(m config.ModelConfig) bool {
+func probeLocalModelAvailability(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
case "vllm":
- return probeOpenAICompatibleModelFunc(apiBase, modelID)
+ return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
case "claude-cli", "claudecli", "codex-cli", "codexcli":
return true
default:
if hasLocalAPIBase(apiBase) {
- return probeOpenAICompatibleModelFunc(apiBase, modelID)
+ return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
}
return false
}
}
-func modelProbeAPIBase(m config.ModelConfig) string {
+func modelProbeAPIBase(m *config.ModelConfig) string {
if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" {
return normalizeModelProbeAPIBase(apiBase)
}
@@ -209,7 +209,7 @@ func probeOllamaModel(apiBase, modelID string) bool {
Model string `json:"model"`
} `json:"models"`
}
- if err := getJSON(root+"/api/tags", &resp); err != nil {
+ if err := getJSON(root+"/api/tags", &resp, ""); err != nil {
return false
}
@@ -221,7 +221,7 @@ func probeOllamaModel(apiBase, modelID string) bool {
return false
}
-func probeOpenAICompatibleModel(apiBase, modelID string) bool {
+func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool {
if strings.TrimSpace(apiBase) == "" {
return false
}
@@ -231,7 +231,7 @@ func probeOpenAICompatibleModel(apiBase, modelID string) bool {
ID string `json:"id"`
} `json:"data"`
}
- if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil {
+ if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp, apiKey); err != nil {
return false
}
@@ -243,11 +243,14 @@ func probeOpenAICompatibleModel(apiBase, modelID string) bool {
return false
}
-func getJSON(rawURL string, out any) error {
+func getJSON(rawURL string, out any, apiKey string) error {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return err
}
+ if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ }
client := &http.Client{Timeout: modelProbeTimeout}
resp, err := client.Do(req)
diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go
new file mode 100644
index 000000000..df942a9e9
--- /dev/null
+++ b/web/backend/api/model_status_test.go
@@ -0,0 +1,37 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T) {
+ const apiKey = "test-api-key"
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/models" {
+ t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models")
+ }
+ if got := r.Header.Get("Authorization"); got != "Bearer "+apiKey {
+ http.Error(w, "missing auth", http.StatusUnauthorized)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"id":"custom-model"}]}`))
+ }))
+ defer srv.Close()
+
+ model := &config.ModelConfig{
+ Model: "openai/custom-model",
+ APIBase: srv.URL + "/v1",
+ }
+ model.SetAPIKey(apiKey)
+
+ if !probeLocalModelAvailability(model) {
+ t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
+ }
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index 7f3d29c77..802b28526 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -31,12 +31,13 @@ type modelResponse struct {
Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields
- ConnectMode string `json:"connect_mode,omitempty"`
- Workspace string `json:"workspace,omitempty"`
- RPM int `json:"rpm,omitempty"`
- MaxTokensField string `json:"max_tokens_field,omitempty"`
- RequestTimeout int `json:"request_timeout,omitempty"`
- ThinkingLevel string `json:"thinking_level,omitempty"`
+ ConnectMode string `json:"connect_mode,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ RPM int `json:"rpm,omitempty"`
+ MaxTokensField string `json:"max_tokens_field,omitempty"`
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"`
+ ExtraBody map[string]any `json:"extra_body,omitempty"`
// Meta
Configured bool `json:"configured"`
IsDefault bool `json:"is_default"`
@@ -58,7 +59,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList {
- go func(i int, m config.ModelConfig) {
+ go func(i int, m *config.ModelConfig) {
defer wg.Done()
configured[i] = isModelConfigured(m)
}(i, m)
@@ -72,7 +73,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
- APIKey: maskAPIKey(m.APIKey),
+ APIKey: maskAPIKey(m.APIKey()),
Proxy: m.Proxy,
AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode,
@@ -81,6 +82,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
+ ExtraBody: m.ExtraBody,
Configured: configured[i],
IsDefault: m.ModelName == defaultModel,
})
@@ -122,7 +124,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
return
}
- cfg.ModelList = append(cfg.ModelList, mc)
+ cfg.ModelList = append(cfg.ModelList, &mc)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@@ -180,11 +182,14 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
// Preserve the existing API key when the caller omits it (empty string).
// This lets the UI update api_base / proxy without clearing the stored secret.
- if mc.APIKey == "" {
- mc.APIKey = cfg.ModelList[idx].APIKey
+ if mc.APIKey() == "" {
+ mc.SetAPIKey(cfg.ModelList[idx].APIKey())
+ }
+ if mc.ExtraBody == nil {
+ mc.ExtraBody = cfg.ModelList[idx].ExtraBody
}
- cfg.ModelList[idx] = mc
+ cfg.ModelList[idx] = &mc
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@@ -224,9 +229,6 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
if cfg.Agents.Defaults.ModelName == deletedModelName {
cfg.Agents.Defaults.ModelName = ""
}
- if cfg.Agents.Defaults.Model == deletedModelName {
- cfg.Agents.Defaults.Model = ""
- }
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
index 2377b5b66..44d10154e 100644
--- a/web/backend/api/models_test.go
+++ b/web/backend/api/models_test.go
@@ -36,11 +36,11 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
var ollamaProbes []string
var tcpProbes []string
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
mu.Lock()
- openAIProbes = append(openAIProbes, apiBase+"|"+modelID)
+ openAIProbes = append(openAIProbes, apiBase+"|"+modelID+"|"+apiKey)
mu.Unlock()
- return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
+ return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == ""
}
probeOllamaModelFunc = func(apiBase, modelID string) bool {
mu.Lock()
@@ -59,7 +59,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{
ModelName: "openai-oauth",
Model: "openai/gpt-5.4",
@@ -78,7 +78,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
ModelName: "vllm-remote",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
- APIKey: "remote-key",
},
{
ModelName: "copilot-gpt-5.4",
@@ -87,6 +86,11 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
AuthMethod: "oauth",
},
}
+ cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
+ "vllm-remote": {
+ APIKeys: []string{"remote-key"},
+ },
+ }})
cfg.Agents.Defaults.ModelName = "openai-oauth"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
@@ -131,7 +135,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
if !got["copilot-gpt-5.4"] {
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds")
}
- if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" {
+ if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
}
if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" {
@@ -152,7 +156,7 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "claude-oauth",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: "oauth",
@@ -205,7 +209,7 @@ func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
started := make(chan string, 2)
release := make(chan struct{})
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
started <- apiBase + "|" + modelID
<-release
return true
@@ -215,7 +219,7 @@ func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{
+ cfg.ModelList = []*config.ModelConfig{
{
ModelName: "local-vllm-a",
Model: "vllm/custom-a",
@@ -265,16 +269,16 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
resetModelProbeHooks(t)
var gotProbe string
- probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
- gotProbe = apiBase + "|" + modelID
- return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
+ probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
+ gotProbe = apiBase + "|" + modelID + "|" + apiKey
+ return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" && apiKey == ""
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "vllm-local",
Model: "vllm/custom-model",
APIBase: "http://0.0.0.0:8000/v1",
@@ -307,7 +311,7 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
if !resp.Models[0].Configured {
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization")
}
- if gotProbe != "http://127.0.0.1:8000/v1|custom-model" {
- t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model")
+ if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
+ t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go
index 4edabb9ab..213b53836 100644
--- a/web/backend/api/oauth.go
+++ b/web/backend/api/oauth.go
@@ -744,17 +744,6 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return err
}
- switch provider {
- case oauthProviderOpenAI:
- cfg.Providers.OpenAI.AuthMethod = authMethod
- case oauthProviderAnthropic:
- cfg.Providers.Anthropic.AuthMethod = authMethod
- case oauthProviderGoogleAntigravity:
- cfg.Providers.Antigravity.AuthMethod = authMethod
- default:
- return fmt.Errorf("unsupported provider %q", provider)
- }
-
found := false
for i := range cfg.ModelList {
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
@@ -787,28 +776,28 @@ func modelBelongsToProvider(provider, model string) bool {
}
}
-func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig {
+func defaultModelConfigForProvider(provider, authMethod string) *config.ModelConfig {
switch provider {
case oauthProviderOpenAI:
- return config.ModelConfig{
+ return &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: authMethod,
}
case oauthProviderAnthropic:
- return config.ModelConfig{
+ return &config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: authMethod,
}
case oauthProviderGoogleAntigravity:
- return config.ModelConfig{
+ return &config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: authMethod,
}
default:
- return config.ModelConfig{}
+ return &config.ModelConfig{}
}
}
diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go
index 7d63abbd4..7cab79b52 100644
--- a/web/backend/api/oauth_test.go
+++ b/web/backend/api/oauth_test.go
@@ -166,8 +166,7 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
- cfg.Providers.OpenAI.AuthMethod = "oauth"
- cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
+ cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
@@ -208,9 +207,6 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
- if updated.Providers.OpenAI.AuthMethod != "" {
- t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod)
- }
for _, m := range updated.ModelList {
if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" {
t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod)
@@ -233,12 +229,18 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) {
}
cfg := config.DefaultConfig()
- cfg.ModelList = []config.ModelConfig{{
+ cfg.ModelList = []*config.ModelConfig{{
ModelName: "custom-default",
Model: "openai/gpt-4o",
- APIKey: "sk-default",
}}
cfg.Agents.Defaults.ModelName = "custom-default"
+ cfg.WithSecurity(&config.SecurityConfig{
+ ModelList: map[string]config.ModelSecurityEntry{
+ "custom-default": {
+ APIKeys: []string{"sk-default"},
+ },
+ },
+ })
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {
diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go
index a880f2f0c..8fbb8737f 100644
--- a/web/backend/api/pico.go
+++ b/web/backend/api/pico.go
@@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token,
+ "token": cfg.Channels.Pico.Token(),
"ws_url": wsURL,
"enabled": cfg.Channels.Pico.Enabled,
})
@@ -74,7 +74,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
}
token := generateSecureToken()
- cfg.Channels.Pico.Token = token
+ cfg.Channels.Pico.SetToken(token)
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
@@ -110,8 +110,8 @@ func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) {
changed = true
}
- if cfg.Channels.Pico.Token == "" {
- cfg.Channels.Pico.Token = generateSecureToken()
+ if cfg.Channels.Pico.Token() == "" {
+ cfg.Channels.Pico.SetToken(generateSecureToken())
changed = true
}
@@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
- "token": cfg.Channels.Pico.Token,
+ "token": cfg.Channels.Pico.Token(),
"ws_url": wsURL,
"enabled": true,
"changed": changed,
diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go
index 075da4ddc..263253cb2 100644
--- a/web/backend/api/pico_test.go
+++ b/web/backend/api/pico_test.go
@@ -33,7 +33,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
if !cfg.Channels.Pico.Enabled {
t.Error("expected Pico to be enabled after setup")
}
- if cfg.Channels.Pico.Token == "" {
+ if cfg.Channels.Pico.Token() == "" {
t.Error("expected a non-empty token after setup")
}
}
@@ -121,7 +121,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
// Pre-configure with custom user settings
cfg := config.DefaultConfig()
cfg.Channels.Pico.Enabled = true
- cfg.Channels.Pico.Token = "user-custom-token"
+ cfg.Channels.Pico.SetToken("user-custom-token")
cfg.Channels.Pico.AllowTokenQuery = true
cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"}
if err := config.SaveConfig(configPath, cfg); err != nil {
@@ -143,8 +143,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
- if cfg.Channels.Pico.Token != "user-custom-token" {
- t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token")
+ if cfg.Channels.Pico.Token() != "user-custom-token" {
+ t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token")
}
if !cfg.Channels.Pico.AllowTokenQuery {
t.Error("user's allow_token_query=true must be preserved")
@@ -166,7 +166,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg1, _ := config.LoadConfig(configPath)
- token1 := cfg1.Channels.Pico.Token
+ token1 := cfg1.Channels.Pico.Token()
// Second call should be a no-op
changed, err := h.ensurePicoChannel(origin)
@@ -178,7 +178,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg2, _ := config.LoadConfig(configPath)
- if cfg2.Channels.Pico.Token != token1 {
+ if cfg2.Channels.Pico.Token() != token1 {
t.Error("token should not change on subsequent calls")
}
}
diff --git a/web/backend/main.go b/web/backend/main.go
index b1db3c57a..8183731fe 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -33,6 +33,10 @@ import (
const (
appName = "PicoClaw"
+
+ logPath = "logs"
+ panicFile = "launcher_panic.log"
+ logFile = "launcher.log"
)
var (
@@ -72,6 +76,14 @@ func main() {
// Initialize logger
picoHome := utils.GetPicoclawHome()
+
+ f := filepath.Join(picoHome, logPath, panicFile)
+ panicFunc, err := logger.InitPanic(f)
+ if err != nil {
+ panic(fmt.Sprintf("error initializing panic log: %v", err))
+ }
+ defer panicFunc()
+
// By default, detect terminal to decide console log behavior
// If -console-logs flag is explicitly set, it overrides the detection
enableConsole := *console
@@ -79,11 +91,9 @@ func main() {
// Disable console logging by setting level to Fatal (no output)
logger.SetConsoleLevel(logger.FATAL)
- logPath := filepath.Join(picoHome, "logs", "web.log")
- if err := logger.EnableFileLogging(logPath); err != nil {
- // FIXME: https://github.com/sipeed/picoclaw/issues/1734
- fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
- os.Exit(1)
+ f := filepath.Join(picoHome, logPath, logFile)
+ if err = logger.EnableFileLogging(f); err != nil {
+ panic(fmt.Sprintf("error enabling file logging: %v", err))
}
defer logger.DisableFileLogging()
}
diff --git a/web/frontend/package.json b/web/frontend/package.json
index b1cc09b7b..8053d1f2a 100644
--- a/web/frontend/package.json
+++ b/web/frontend/package.json
@@ -31,6 +31,8 @@
"react-i18next": "^16.5.8",
"react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml
index f893abda9..edaf49ccc 100644
--- a/web/frontend/pnpm-lock.yaml
+++ b/web/frontend/pnpm-lock.yaml
@@ -62,6 +62,12 @@ importers:
react-textarea-autosize:
specifier: ^8.5.9
version: 8.5.9(@types/react@19.2.14)(react@19.2.4)
+ rehype-raw:
+ specifier: ^7.0.0
+ version: 7.0.0
+ rehype-sanitize:
+ specifier: ^6.0.0
+ version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -2155,6 +2161,10 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -2467,12 +2477,30 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-from-parse5@8.0.3:
+ resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+
+ hast-util-parse-selector@4.0.0:
+ resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
+
+ hast-util-raw@9.1.0:
+ resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+
+ hast-util-sanitize@5.0.2:
+ resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+ hast-util-to-parse5@8.0.1:
+ resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+ hastscript@9.0.1:
+ resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
@@ -2492,6 +2520,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+ html-void-elements@3.0.0:
+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3141,6 +3172,9 @@ packages:
parse-statements@1.0.11:
resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
+ parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3390,6 +3424,12 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
+ rehype-raw@7.0.0:
+ resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+
+ rehype-sanitize@6.0.0:
+ resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -3812,6 +3852,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
+ vfile-location@5.0.3:
+ resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
+
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -3862,6 +3905,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
+ web-namespaces@2.0.1:
+ resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -5945,6 +5991,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@6.0.1: {}
+
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -6318,6 +6366,43 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-parse5@8.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ hastscript: 9.0.1
+ property-information: 7.1.0
+ vfile: 6.0.3
+ vfile-location: 5.0.3
+ web-namespaces: 2.0.1
+
+ hast-util-parse-selector@4.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-raw@9.1.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ '@ungap/structured-clone': 1.3.0
+ hast-util-from-parse5: 8.0.3
+ hast-util-to-parse5: 8.0.1
+ html-void-elements: 3.0.0
+ mdast-util-to-hast: 13.2.1
+ parse5: 7.3.0
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
+ hast-util-sanitize@5.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ unist-util-position: 5.0.0
+
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
@@ -6338,10 +6423,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ hast-util-to-parse5@8.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
+ hastscript@9.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ hast-util-parse-selector: 4.0.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+
headers-polyfill@4.0.3: {}
hermes-estree@0.25.1: {}
@@ -6358,6 +6461,8 @@ snapshots:
html-url-attributes@3.0.1: {}
+ html-void-elements@3.0.0: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -7135,6 +7240,10 @@ snapshots:
parse-statements@1.0.11: {}
+ parse5@7.3.0:
+ dependencies:
+ entities: 6.0.1
+
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@@ -7369,6 +7478,17 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
+ rehype-raw@7.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-raw: 9.1.0
+ vfile: 6.0.3
+
+ rehype-sanitize@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-sanitize: 5.0.2
+
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -7860,6 +7980,11 @@ snapshots:
vary@1.1.2: {}
+ vfile-location@5.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile: 6.0.3
+
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -7887,6 +8012,8 @@ snapshots:
void-elements@3.1.0: {}
+ web-namespaces@2.0.1: {}
+
web-streams-polyfill@3.3.3: {}
webpack-virtual-modules@0.6.2: {}
diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts
index 8e49b48b4..2fd042593 100644
--- a/web/frontend/src/api/models.ts
+++ b/web/frontend/src/api/models.ts
@@ -17,6 +17,7 @@ export interface ModelInfo {
max_tokens_field?: string
request_timeout?: number
thinking_level?: string
+ extra_body?: Record
// Meta
configured: boolean
is_default: boolean
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 150f2f87d..05da3ceb1 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -1,6 +1,8 @@
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
import ReactMarkdown from "react-markdown"
+import rehypeRaw from "rehype-raw"
+import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
@@ -42,7 +44,12 @@ export function AssistantMessage({
- {content}
+
+ {content}
+
-
+
{selectedSkillDetail.content}